普通视图

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

从零实现 React Native(2): 跨平台支持

作者 zerosrat
2026年1月13日 19:21

上一回:从零实现 React Native(1): 桥通信的原理与实现

平台支持的取舍

在上一篇《从零实现 React Native(1): 桥通信的原理与实现》中,基于 macos 平台实现了 JS 与 Native 的双向桥通信,在本篇中将对其他平台进行支持,实现「write once,run anywhere」这个理念。

接下来便来进行 iOS 和 Android 平台的支持工作。

iOS 进展顺利

在支持了 macos 端后,支持 iOS 是很容易的,可以马上着手来搞这个事情。得益于 Apple 生态带来的:macOS 和 iOS 都内置了 JavaScriptCore.framework,这意味着无需额外的引擎移植工作;且编程 API 很相似,这意味着差异化实现较少,大多可复用或类比实现。

事实上,我只花了半天时间就完成了 iOS 端的支持工作,其中主要的时间花在了构建配置的修改、测试示例的新增和调整,少部分时间花在了差异化的 DeviceInfo 模块实现。

得益于 Apple 生态,iOS 的支持工作中大部分代码都是复用的,复用率 90%。因为 macos 和 iOS 的 JSC API 一致,以及 C++ 语言的优势,可以用于跨端复用。复用的内容包含:

  • JSCExector
  • Bridge 通信逻辑
  • 模块注册逻辑

Android 滑铁卢

在顺利支持了 iOS 后,预想是 Android 的支持也不会太难,但实际做起来发现没这么简单。

记得是周末的午后的轻松下午,我先把 Android 的相关环境搭建好(包括 Android Studio、Java SDK 及其环境变量、NDK 等),然后进入 JSC 的移植工作。Why JSC 移植?因为不同于 Apple 生态,Android 系统是没有内置 JSC 引擎的。而正是这一步让我陷入泥潭。

我首先尝试了三方编译的版本,但是要么遇到了 libjsc.so(JSC 编译后的二进制文件,可供 Android 平台运行,可类比理解为前端的 wasm) 不支持 arm64(由于是 MBP 机器,安卓模拟器必须用 arm64 而非 x86 架构的),要么是遇到了 libjsc.so 和 NDK 版本不兼容。然后尝试了从社区提供的 jsc-android-buildscripts 自行编译,也遇到了各种问题编译失败,考虑到每次编译时间:2-3 小时,这也是一个艰难的旅程。

就算解决了 JavaScriptCore,还有 JNI 在等着我。Java 和 C++ 之间的桥梁不是简单的函数调用。我要处理诸如:类型转换、线程同步等问题。前方有很多新的坑在等着我。

舍与得

Maybe it's not the right time. 先理解核心,再扩展边界。先放下 Android 的支持,或许未来的某一天再回头来看这件事。

这个决定让我想起了 MVP(最小可行产品)的原则:先让核心功能跑通,再逐步完善。在学习项目中,这个原则同样适用——先掌握本质,再扩展边界。

既然决定专注于 iOS 和 macOS 双平台,那么接下来就需要一套优雅的构建系统来支撑跨平台开发。一个好的构建系统不仅能让开发者轻松切换平台,更重要的是,它能为后续的代码复用奠定基础。

构建系统的演进

在上一篇博客中,受制于篇幅的限制,跳过了对构建系统的讲解。而在跨平台支持中,天然需要迭代构建系统,也正是对其展开讲讲的一个好时机。

Make 是什么

Make 是一个诞生于 1976 年的构建工具,它的工作原理很简单:描述文件之间的依赖关系,然后只重新编译"变化过的"文件。

Make 适合于 需要多步骤构建流程 的项目,本项目的构建流程较为复杂:JS 产物打包 -> CMake 配置 -> C++ 产物编译 -> 运行 test 代码,很适合引入 Make 进行任务流程的编排。

Make 工具的配套 Makefile 文件是一个文本配置文件,它定义了构建规则、依赖关系和执行命令,可以将其理解为 npm 和 package.json 的关系。

以下是基于 macos 编译和测试的 Makefile 文件摘要代码,核心步骤包含了 js-build, configure, test

# ============================================
# 变量定义 (Makefile 第 10-13 行)
# ============================================
BUILD_DIR = build
CMAKE_BUILD_TYPE ?= Debug
CORES = $(shell sysctl -n hw.ncpu)  # 动态检测 CPU 核心数

# ============================================
# 主要构建目标 - 依赖链设计
# ============================================

# 默认目标:make 等价于 make build
.PHONY: all
all: build

# 核心构建流程:js-build → configure → 实际编译
.PHONY: build
build: js-build configure
    @echo "🔨 Building Mini React Native..."
    @cd $(BUILD_DIR) && make -j$(CORES)
    @echo "✅ Build complete"

# ============================================
# 步骤 1:JavaScript 构建 (第 29-33 行)
# ============================================
.PHONY: js-build
js-build:
    @echo "📦 Building JavaScript bundle..."
    @npm run build    # 执行 rollup -c,生成 dist/bundle.js
    @echo "✅ JavaScript bundle built"

# ============================================
# 步骤 2:CMake 配置 (第 22-26 行)
# ============================================
.PHONY: configure
configure:
    @echo "🔧 Configuring build system..."
    @mkdir -p $(BUILD_DIR)
    @cd $(BUILD_DIR) && cmake -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) -DCMAKE_EXPORT_COMPILE_COMMANDS=ON ..
    @echo "✅ Configuration complete"


# ============================================
# 步骤 4:分层测试流程 (第 91-130 行)
# ============================================

# 完整测试:build → 4 个测试依次执行
.PHONY: test
test: build
    @echo "🧪 Running all tests..."
    @echo "\n📝 Test 1: Basic functionality test"
    @./$(BUILD_DIR)/mini_rn_test
    @echo "\n📝 Test 2: Module framework test"
    @./$(BUILD_DIR)/test_module_framework
    @echo "\n📝 Test 3: Integration test"
    @./$(BUILD_DIR)/test_integration
    @echo "\n📝 Test 4: Performance test"
    @./$(BUILD_DIR)/test_performance
    @echo "\n✅ All tests complete"

# 单独的测试目标 - 允许细粒度测试
.PHONY: test-basic
test-basic: build
    @echo "🧪 Running basic functionality test..."
    @./$(BUILD_DIR)/mini_rn_test

.PHONY: test-performance
test-performance: build
    @echo "🧪 Running performance test..."
    @./$(BUILD_DIR)/test_performance

在引入了 make 后,可以很方便的进行复杂流程的编排,例如我们想要运行测试代码时,实际的发生的事情如下所示。

用户命令: make test
    ↓
test: build
    ↓
build: js-build configure
        ↓             ↓
    js-build          configure
        ↓                   ↓
    npm run build       cmake ..
        ↓                   ↓
    dist/bundle.js      build/Makefile
                            ↓
                        make -j8 (CMake 管理的依赖)
                            ↓
                        libmini_react_native.a
                            ↓
                        mini_rn_test (等 4 个可执行文件)

Before 引入 Make:想象一下,如果没有 Make,每次修改代码后你需要手动执行

# 步骤1:构建 JavaScript
npm run build

# 步骤2:配置 CMake
mkdir -p build
cd build && cmake ..

# 步骤3:编译 C++
cd build && make -j8

# 步骤4:运行测试
./build/mini_rn_test
./build/test_module_framework
./build/test_integration
./build/test_performance

每次都要记住这么多命令,还要确保执行顺序正确。更糟糕的是,如果某个步骤失败了,你需要手动判断从哪里重新开始。

After 引入 Makemake test 一条命令搞定所有事情

CMake 是什么

在把 C++ 代码编译成二进制文件这一步之前,其实构建系统提前引入了 CMake 进行管理。CMake 不是“构建工具”,而是“构建系统的构建系统”,在这个场景中 CMake 实际上生成了编译代码的工具 Makefile 文件。CMake 会读取 CMakeLists.txt,然后生成原生的构建文件。

Why CMake?因为 mini-rn 项目开始之初就是要考虑多平台支持的,为了实现这个 feature,便会遇到 多平台构建的复杂性 这个问题。

问题 1:平台特定源文件管理

不同平台需要不同的实现:

  • macOS:使用 IOKit 获取硬件信息
  • iOS:使用 UIDevice 获取设备信息

没有 CMake 需要维护两套构建脚本,引入 CMake 后可通过条件编译一套配置搞定。

问题 2:系统框架动态链接

不同平台需要链接不同框架:macOS 需要 IOKit,iOS 需要 UIKit

引入 CMake 后可自动检测并链接正确的框架。

解决效果

引入 CMake 前:需要维护多套构建脚本,手动管理复杂配置,容易出错。

引入 CMake 后:一套 CMakeLists.txt 支持所有平台,自动处理平台差异,大幅降低维护成本。

CMake 关键语法解释

  • CMAKE_SYSTEM_NAME:CMake 内置变量,表示目标系统名称(iOS、Darwin等)
  • find_library():在系统中查找指定的库文件
  • target_link_libraries():将库文件链接到目标可执行文件
  • set():设置变量的值
  • if(MATCHES):条件判断,支持正则表达式匹配

改动一:Makefile 新增 iOS 构建目标

在 macOS 的可扩展构建系统配置就绪后,接下来看看如何改动以支持 iOS。

改动一实现了什么?

核心目标:在现有 Makefile 基础上,新增 iOS 平台的完整构建流程,实现"一套 Makefile,双平台支持"。

具体实现

  1. 新增 4 个 iOS 专用目标ios-configureios-buildios-testios-test-deviceinfo
  2. 建立 iOS 构建流程:js-build → ios-configure → ios-build → ios-test
  3. 实现平台隔离:iOS 使用独立的 build_ios/ 目录,与 macOS 的 build/ 目录完全分离
  4. 自动化 Xcode 环境配置:自动检测 SDK 路径、设置开发者目录、配置模拟器架构

新增的 4 个 iOS 目标

原本基于 macOS 的构建路径是:js-build → configure → build → test,现在为 iOS 新增了对应的平行路径:js-build → ios-configure → ios-build → ios-test。

# iOS 构建配置(模拟器)
.PHONY: ios-configure
ios-configure:
    @mkdir -p $(BUILD_DIR)_ios
    @cd $(BUILD_DIR)_ios && DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer cmake \
        -DCMAKE_SYSTEM_NAME=iOS \
        -DCMAKE_OSX_ARCHITECTURES=$$(uname -m) \
        -DCMAKE_OSX_SYSROOT=$$(DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcrun --sdk iphonesimulator --show-sdk-path) \
        -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \
        -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
        ..

# 构建 iOS 版本(模拟器)
.PHONY: ios-build
ios-build: js-build ios-configure
    @cd $(BUILD_DIR)_ios && make -j$(CORES)

# iOS 测试目标
.PHONY: ios-test
ios-test: ios-build
    @./test_ios.sh all

# iOS DeviceInfo 测试
.PHONY: ios-test-deviceinfo
ios-test-deviceinfo: ios-build
    @./test_ios.sh deviceinfo

关键设计决策

1. 独立构建目录

macOS 用 build/,iOS 用 build_ios/,互不干扰:

@mkdir -p $(BUILD_DIR)_ios   # iOS 构建目录

2. 仅支持 iOS 模拟器

为什么不支持真机?因为:

  • 真机需要开发者证书和配置文件
  • 模拟器足够验证 Bridge 通信机制
  • 降低环境配置复杂度
-DCMAKE_OSX_SYSROOT=$$(xcrun --sdk iphonesimulator --show-sdk-path)

3. 语义化命令

make ios-configure 比写一长串 CMake 命令简洁太多。这就是 Makefile 作为用户接口的价值。

改动二:CMake 平台条件编译

改动二实现了什么?

核心目标:让 CMake 能够智能识别目标平台,并自动选择正确的源文件和系统框架,实现"一套 CMakeLists.txt,智能适配双平台"。

具体实现

  1. 平台检测机制:通过 CMAKE_SYSTEM_NAME 变量自动识别是 macOS 还是 iOS
  2. 源文件智能选择:根据平台自动选择对应的 .mm 实现文件
  3. 框架动态链接:iOS 链接 UIKit,macOS 链接 IOKit,共享 JavaScriptCore 和 Foundation
  4. 编译标志自动设置:为 Objective-C++ 文件自动设置 ARC 标志
  5. 部署目标配置:iOS 设为 12.0+,macOS 设为 10.15+

设计精髓:编译时确定,运行时无开销。最终的 iOS 二进制文件中完全没有 macOS 代码,反之亦然。

原来的代码(仅 macOS)

# 原始版本 - 仅支持 macOS
if(APPLE)
    set(PLATFORM_SOURCES
        src/macos/modules/deviceinfo/DeviceInfoModule.mm
    )
    find_library(IOKIT_FRAMEWORK IOKit)
endif()

target_link_libraries(mini_react_native
    ${JAVASCRIPTCORE_FRAMEWORK}
    ${IOKIT_FRAMEWORK}
)

演进后的代码(macOS + iOS)

# 演进版本 - 支持 macOS + iOS
if(APPLE)
    # 根据具体平台选择源文件
    if(${CMAKE_SYSTEM_NAME} MATCHES "iOS")
        set(PLATFORM_SOURCES
            src/ios/modules/deviceinfo/DeviceInfoModule.mm
        )
    else()
        # macOS
        set(PLATFORM_SOURCES
            src/macos/modules/deviceinfo/DeviceInfoModule.mm
        )
    endif()

    # 平台特定框架
    if(${CMAKE_SYSTEM_NAME} MATCHES "iOS")
        find_library(UIKIT_FRAMEWORK UIKit)
        set(PLATFORM_FRAMEWORKS ${UIKIT_FRAMEWORK})
    else()
        find_library(IOKIT_FRAMEWORK IOKit)
        set(PLATFORM_FRAMEWORKS ${IOKIT_FRAMEWORK})
    endif()

    # 统一链接
    target_link_libraries(mini_react_native
        ${JAVASCRIPTCORE_FRAMEWORK}
        ${FOUNDATION_FRAMEWORK}
        ${PLATFORM_FRAMEWORKS}
    )
endif()

三个关键变化

1. 源文件分离

src/
├── macos/modules/deviceinfo/DeviceInfoModule.mm
└── ios/modules/deviceinfo/DeviceInfoModule.mm

两个文件虽然文件名相同,但实现不同:

  • macOS 版本:用 IOKit 获取硬件信息
  • iOS 版本:用 UIDevice 获取设备信息

2. 框架动态链接

平台 共享框架 平台特定框架
macOS JavaScriptCore, Foundation IOKit
iOS JavaScriptCore, Foundation UIKit

3. 部署目标设置

if(${CMAKE_SYSTEM_NAME} MATCHES "iOS")
    set(CMAKE_OSX_DEPLOYMENT_TARGET "12.0")
elseif(${CMAKE_SYSTEM_NAME} MATCHES "Darwin")
    set(CMAKE_OSX_DEPLOYMENT_TARGET "10.15")
endif()

两个改动的协同作用

改动一 + 改动二 = 完美的跨平台构建系统

这两个改动巧妙地分工合作:

  • Makefile(改动一):作为用户接口层,提供简单统一的命令,隐藏平台配置的复杂性
  • CMake(改动二):作为构建逻辑层,智能处理平台差异,自动选择正确的源文件和框架

协同效果

  1. 开发者体验make build vs make ios-build,命令接口一致
  2. 构建隔离:两个平台使用独立目录,可以并行构建,切换无需清理
  3. 智能适配:CMake 根据 Makefile 传入的平台信息,自动配置所有细节
  4. 零运行时开销:编译时就确定了平台,最终二进制文件纯净无冗余

这种设计让跨平台支持变得既强大又优雅:开发者只需要记住两个命令,背后的所有复杂性都被自动化处理了。

DeviceInfo - 变与不变

在构建系统演进完成后,我们来深入分析 DeviceInfo 模块的双平台实现。这个模块展示了跨平台架构设计的智慧:如何在保持接口统一的同时,让每个平台发挥自身优势。

90% 复用率是怎么做到的?

关键洞察:大部分逻辑其实是平台无关的

仔细分析 DeviceInfo 模块,你会发现一个惊人的事实:

// 这些逻辑在任何平台都一样
std::string DeviceInfoModule::getName() const {
    return "DeviceInfo";
}

std::vector<std::string> DeviceInfoModule::getMethods() const {
    return {
        "getUniqueId",       // methodId = 0
        "getSystemVersion",  // methodId = 1
        "getDeviceId"        // methodId = 2
    };
}

void DeviceInfoModule::invoke(const std::string& methodName,
                             const std::string& args, int callId) {
    try {
        if (methodName == "getUniqueId") {
            std::string uniqueId = getUniqueIdImpl();  // 只是调用,不关心具体实现
            sendSuccessCallback(callId, uniqueId);
        } else {
            sendErrorCallback(callId, "Unknown method: " + methodName);
        }
    } catch (const std::exception& e) {
        sendErrorCallback(callId, "Method invocation failed: " + std::string(e.what()));
    }
}

**Bridge 通信协议、方法注册机制、消息分发逻辑,完全都是可以复用的!**真正不同的,只是那几个 xxxImpl() 方法的底层实现。

复用的边界

但这里有个更深层的问题:为什么有些代码能 100% 复用,有些却完全不能?

让我们看看实际的复用率统计:

代码类型 复用率 为什么?
Bridge 通信逻辑 100% 协议标准化
模块注册机制 100% 框架层抽象
错误处理机制 100% 异常处理逻辑相同
设备唯一标识 0% 平台理念完全不同
系统版本获取 95% 只有注释不同
设备型号获取 85% 都用 sysctlbyname,iOS多了模拟器判断

100% 复用:协议的力量

为什么 Bridge 通信能 100% 复用?

因为这是协议层,不管底层平台怎么变,JavaScript 和 Native 之间的通信协议是固定的。方法名、参数、回调 ID、错误处理这些都是标准化的。就像 HTTP 协议,不管服务器是 Linux 还是 Windows,浏览器都用同样的方式发请求。

0% 复用:平台的鸿沟

为什么设备唯一标识完全不能复用?

macOS 追求真正的硬件级别唯一性,有复杂的降级机制;iOS 在 MVP 阶段采用了简化策略,每次启动生成新ID。这不是技术问题,而是:

  1. 平台哲学的差异:桌面 vs 移动的隐私理念
  2. 开发策略的差异:完整实现 vs MVP验证

复用边界的哲学

通过 DeviceInfo 模块,我们发现了跨平台复用的三个层次:

  1. 协议层:100% 复用,因为标准统一
  2. API 层:看运气,苹果生态有优势
  3. 实现层:看平台差异,移动端更复杂

这揭示了一个残酷的真相:跨平台的成本永远存在,只是被转移了。

可以用抽象基类隐藏差异,但差异本身不会消失。关键是找到合适的边界,让复用最大化,让差异最小化。

头文件的魔法

解决方案其实就是基于 面向对象 的:

// common/modules/DeviceInfoModule.h
class DeviceInfoModule : public NativeModule {
public:
    DeviceInfoModule();
    ~DeviceInfoModule() override = default;

    // NativeModule 接口实现 - 所有平台共享
    std::string getName() const override;
    std::vector<std::string> getMethods() const override;
    void invoke(const std::string& methodName, const std::string& args,
                int callId) override;

    // 平台特定的实现接口 - 让各平台去填这些"洞"
    std::string getUniqueIdImpl() const;
    std::string getSystemVersionImpl() const;
    std::string getDeviceIdImpl() const;

private:
    // 工具方法
    std::string createSuccessResponse(const std::string& data) const;
    std::string createErrorResponse(const std::string& error) const;
};

注意这里没有用虚函数,因为已经引入了 CMake 在编译时确定了对应平台的文件,不需要运行时多态,结果是 同一个头文件,不同的实现文件。每个平台都有自己的 .mm 文件来实现这些方法,编译时自动选择对应的实现。

基类定义了 what(做什么),各平台实现 how (怎么做)。Bridge 通信、方法注册、消息分发等这些复杂的逻辑只写一遍,所有平台自动继承。

分平台实现

// macOS 实现 - src/macos/modules/deviceinfo/DeviceInfoModule.mm
std::string DeviceInfoModule::getUniqueIdImpl() const {
    @autoreleasepool {
        // 尝试获取硬件 UUID
        io_registry_entry_t ioRegistryRoot =
            IORegistryEntryFromPath(kIOMasterPortDefault, "IOService:/");
        CFStringRef uuidCf = (CFStringRef)IORegistryEntryCreateCFProperty(
            ioRegistryRoot, CFSTR(kIOPlatformUUIDKey), kCFAllocatorDefault, 0);

        if (uuidCf) {
            NSString* uuid = (__bridge NSString*)uuidCf;
            std::string result = [uuid UTF8String];
            CFRelease(uuidCf);
            return result;
        }
        // 多层降级机制...
        return "macOS-" + getDeviceIdImpl() + "-" + getSystemVersionImpl();
    }
}

// iOS 实现 - src/ios/modules/deviceinfo/DeviceInfoModule.mm
std::string DeviceInfoModule::getUniqueIdImpl() const {
    @autoreleasepool {
        // iOS 简化实现:使用 NSUUID 生成唯一标识
        // 注意:这个实现每次启动都会生成新的ID,适用于MVP测试
        NSUUID* uuid = [NSUUID UUID];
        NSString* uuidString = [uuid UUIDString];
        return [uuidString UTF8String];
    }
}

Objective-C++ 关键字解释

  • @autoreleasepool:自动释放池,管理 Objective-C 对象的内存,确保及时释放
  • __bridge:ARC(自动引用计数)中的桥接转换,在 C/C++ 指针和 Objective-C 对象间转换
  • [object method]:Objective-C 的方法调用语法
  • .mm 文件扩展名:表示 Objective-C++ 文件,可以混合使用 C++、C 和 Objective-C 代码

两个平台的实现文件自动拥有了完整的 Bridge 通信能力,现在只需要实现平台差异部分即可~

应自动化尽自动化

DeviceInfo 模块的自动化实现揭示了一个重要原则:

好的跨平台架构不是让代码在所有平台都能跑,而是让正确的代码在正确的平台上跑。

通过这个项目的三层自动化体系:

  1. Makefile 自动化:统一的命令接口,隐藏平台配置复杂性
  2. CMake 自动化:智能的源文件选择和框架链接
  3. 编译器自动化:平台特定的二进制生成

这样的架构让开发者专注于业务逻辑,而把平台适配的复杂性交给了工具链。

真正的自动化不是写一份代码到处跑,而是:

  • 开发体验统一make build vs make ios-build,命令接口一致
  • 实现策略分离:每个平台有最适合的实现方式
  • 构建过程透明:开发者不需要关心 Xcode SDK 路径、编译标志等细节

这种设计在面对更复杂的系统时依然有效:只要保持接口统一、实现分离、构建自动化,就能优雅地扩展到视图渲染、事件处理等更复杂的场景。

彩蛋

项目地址: github.com/zerosrat/mi…

当前项目中包含了本篇文章中的全部内容:

  • ✅ iOS 构建系统适配
  • ✅ iOS 跨平台的差异化实现(DeviceInfo)

完成本阶段后,项目已经具备了进入第三阶段的基础:视图渲染系统


📝 本文首发于个人博客: zerosrat.dev/n/2025/mini…

useEffect 空依赖 + 定时器 = 闭包陷阱?count 永远停在 1 的坑我踩透了

2026年1月13日 18:55

写 React 时,你有没有遇到过「定时器里的 state 永远不更新」的诡异情况?比如明明写了setCount(count + 1),页面上的count却永远停在 1—— 这其实是 ** 闭包陷阱(Stale Closure)** 在搞鬼。

今天用一个极简示例,拆解这个坑的本质,再给你 2 个一劳永逸的解决方案。

一、先看复现:count 为什么永远停在 1?

先看这段 “看似没问题” 的代码:

carbon.png

运行结果:页面上的count从 0 变成 1 后,就再也不涨了。

二、核心原因:闭包 “定格” 了初始 state

问题出在 2 个关键点的叠加:

  1. useEffect 的空依赖[] :空依赖意味着useEffect只在组件挂载时执行 1 次,后续组件更新不会重新运行这个 effect。
  2. 闭包捕获了 “快照” 值useEffect执行时,内部的setInterval函数形成了闭包 —— 它 “抓住” 了当时的count(值为 0)。后续count虽然被更新,但因为useEffect没重新执行,这个闭包永远拿着初始值 0,所以setCount(count + 1)永远是0 + 1 = 1

三、2 个解决方案:从根源避开闭包陷阱

针对这个场景,推荐 2 种既简单又安全的写法:

方案 1:函数式更新(推荐)

setState函数式写法,直接获取最新的 state 值,绕开闭包的旧值:

carbon (1).png

原理setCount(c => c + 1)会从 React 内部获取当前最新的count值,不管闭包抓的是旧值,都能拿到最新数据。

方案 2:补全依赖数组

count加入useEffect的依赖数组,让useEffectcount变化时重新执行,生成新的闭包:

carbon (2).png

注意:这个方案会频繁创建 / 清理定时器(每次count变化都重新执行 effect),性能不如方案 1,仅推荐在 “必须依赖外部变量” 的场景使用。

四、避坑总结:useEffect + 定时器的正确姿势

  1. 优先用函数式更新setState(prev => prev + 1)是避开闭包陷阱的 “万能钥匙”;
  2. 空依赖要谨慎:空依赖的useEffect里,尽量避免直接引用 state/props,改用函数式更新;
  3. 依赖数组要写全:如果必须依赖外部变量,一定要把变量加入依赖数组(配合 ESLint 的react-hooks/exhaustive-deps规则)。

React + Ts eslint配置

作者 SsunmdayKT
2026年1月13日 18:32

你现在需要的是 React + TypeScript 项目中配置 ESLint 所需的 npm 包,以及对应的安装和配置方法,我会按照 Vue3+TS 相同的清晰逻辑为你讲解。

一、核心依赖包(分基础和 React/TS 适配)

React + TS 项目的 ESLint 依赖同样分为基础核心包适配 React/TS 的插件包,以下是完整列表及作用:

包名 作用
eslint ESLint 核心库,提供代码检查基础能力
@typescript-eslint/eslint-plugin TypeScript 专属 ESLint 规则插件
@typescript-eslint/parser ESLint 解析 TS 代码的解析器
eslint-plugin-react React 专属 ESLint 插件(支持 React 18+)
eslint-plugin-react-hooks 检查 React Hooks 使用规范(如依赖项、规则 Hooks 调用)
eslint-plugin-react-refresh(可选) 检查 React 组件热更新相关规范(Vite 项目推荐)
eslint-config-prettier(可选) 禁用 ESLint 中与 Prettier 冲突的规则
eslint-plugin-prettier(可选) 将 Prettier 规则集成到 ESLint 中

二、安装命令

1. 基础安装(仅 ESLint + React + TS)

bash

运行

# npm 安装
npm install eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-react eslint-plugin-react-hooks --save-dev

# yarn 安装
yarn add eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-react eslint-plugin-react-hooks -D

# pnpm 安装(推荐)
pnpm add eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-react eslint-plugin-react-hooks -D

2. 包含 Prettier + 热更新检查(推荐)

如果需要 Prettier 格式化 + React 热更新检查,补充安装:

bash

运行

pnpm add prettier eslint-config-prettier eslint-plugin-prettier eslint-plugin-react-refresh --save-dev

三、核心配置(.eslintrc.js)

在项目根目录创建 .eslintrc.js 文件,这是 React + TS 最常用的配置模板:

javascript

运行

module.exports = {
  // 指定代码运行环境,启用对应全局变量
  env: {
    browser: true, // 浏览器环境(React 运行环境)
    es2021: true,  // 支持 ES2021 语法
    node: true     // Node.js 环境(如配置文件、脚本)
  },
  // 继承已有规则集,减少重复配置
  extends: [
    'eslint:recommended', // ESLint 官方推荐规则
    'plugin:@typescript-eslint/recommended', // TS 推荐规则
    'plugin:react/recommended', // React 推荐规则
    'plugin:react/jsx-runtime', // 适配 React 17+ 的 JSX 自动导入(无需手动 import React)
    'plugin:react-hooks/recommended', // React Hooks 强制规则
    'eslint-config-prettier' // 禁用与 Prettier 冲突的规则(装了 Prettier 才加)
    // 'plugin:prettier/recommended' // 开启 Prettier 作为 ESLint 规则(装了 eslint-plugin-prettier 才加)
  ],
  // 指定解析器(TS 解析器)
  parser: '@typescript-eslint/parser',
  // 解析器选项
  parserOptions: {
    ecmaVersion: 'latest', // 支持最新 ES 版本
    sourceType: 'module',  // 模块化代码(ES Module)
    ecmaFeatures: {
      jsx: true // 支持 JSX 语法(React 核心)
    }
  },
  // 启用的插件
  plugins: [
    '@typescript-eslint',
    'react',
    'react-hooks',
    'react-refresh' // 可选,热更新检查
  ],
  // 自定义规则(按需调整)
  rules: {
    // 关闭 TS any 类型禁止规则(新手项目可临时关闭)
    '@typescript-eslint/no-explicit-any': 'off',
    // React Hooks 必选规则(强制检查依赖项)
    'react-hooks/rules-of-hooks': 'error',
    'react-hooks/exhaustive-deps': 'warn',
    // 禁用 React 组件文件名必须 PascalCase 的检查(可选)
    'react/filename-rules': 'off',
    // 关闭 React 必须声明 props 类型的检查(TS 已做类型检查,无需重复)
    'react/prop-types': 'off',
    // 热更新检查:禁止默认导出(React 组件推荐命名导出)
    'react-refresh/only-export-components': [
      'warn',
      { allowConstantExport: true }
    ]
  },
  // 针对 React 版本的配置(自动检测)
  settings: {
    react: {
      version: 'detect'
    }
  }
}

四、补充配置(可选但推荐)

  1. 忽略文件(.eslintignore) :指定 ESLint 不检查的文件 / 目录

plaintext

node_modules/
dist/
build/
*.d.ts
.vscode/
  1. package.json 脚本:添加检查 / 修复命令

json

{
  "scripts": {
    "lint": "eslint . --ext .js,.jsx,.ts,.tsx", // 检查所有 React/TS 文件
    "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix" // 自动修复可修复问题
  }
}

五、特殊说明(针对 Create React App 项目)

如果你的 React 项目是通过 create-react-app 创建的(内置了 ESLint),无需手动安装核心包,只需:

  1. 安装缺失的适配包:

bash

运行

pnpm add eslint-plugin-react-hooks @typescript-eslint/eslint-plugin @typescript-eslint/parser --save-dev
  1. 在项目根目录创建 .eslintrc.js 覆盖默认配置即可。

总结

  1. 核心必装包eslint + @typescript-eslint/*(解析器 + 插件) + eslint-plugin-react + eslint-plugin-react-hooks
  2. 可选扩展eslint-plugin-react-refresh(热更新)、eslint-config-prettier/eslint-plugin-prettier(兼容 Prettier);
  3. 关键配置.eslintrc.js 中需启用 jsx: true 支持 React 语法,通过 settings 自动检测 React 版本,同时开启 React Hooks 核心规则。

安装完成后运行 npm run lint 即可检查代码,npm run lint:fix 可自动修复缩进、空格等格式问题。

每组件(Per-Component)与集中式(Centralized)i18n

2026年1月13日 18:20

每组件(per-component)方法并非新概念。例如,在 Vue 生态系统中,vue-i18n 支持 SFC i18n(单文件组件)。Nuxt 也提供 按组件翻译,Angular 通过其 Feature Modules 采用类似的模式。

即使在 Flutter 应用中,我们也常能发现这样的模式:

lib/
└── features/
    └── login/
        ├── login_screen.dart
        └── login_screen.i18n.dart  # <- 翻译存放在这里
import 'package:i18n_extension/i18n_extension.dart';

extension Localization on String {
  static var _t = Translations.byText("en") +
      {
        "Hello": {
          "en": "Hello",
          "fr": "Bonjour",
        },
      };

  String get i18n => localize(this, _t);
}

然而在 React 领域,我们主要看到不同的做法,我会将它们分为三类:

集中式方法(i18next、next-intl、react-intl、lingui)

  • (无命名空间)将内容视为单一来源进行检索。默认情况下,当应用加载时,会从所有页面加载内容。

细粒度方法 (intlayer, inlang)

  • 按键或按组件对内容检索进行细化。

在本博文中,我不会专注于基于编译器的解决方案,我已经在这里覆盖过:Compiler vs Declarative i18n. 注意,基于编译器的 i18n(例如 Lingui)只是自动化了内容的提取和加载。在底层,它们通常与其他方法共享相同的限制。

注意,你越细化内容的检索方式,就越有可能将额外的 state 和逻辑插入到组件中。

细粒度方法比集中式方法更灵活,但这通常是一种权衡。即使这些 libraries 宣称支持 "tree shaking",在实际中,你通常仍会以每种语言加载整个页面。

所以,概括来说,决策大致可以分为:

  • 如果你的应用页面数量多于语言数量,应优先采用细粒度方法。
  • 如果语言数量多于页面数量,则应倾向于集中式方法。

当然,library 的作者们也意识到这些限制并提供了解决方案。 其中包括:将内容拆分为命名空间、动态加载 JSON 文件(await import()),或在构建时剔除内容。

与此同时,你应该知道,当你动态加载内容时,会向服务器引入额外的请求。每多一个 useState 或 hook,就意味着一次额外的服务器请求。

为了解决这一点,Intlayer 建议将多个内容定义分组到同一个键下,Intlayer 会合并这些内容。

但综观这些解决方案,最流行的方法显然是集中式的。

那么为什么集中式方法如此受欢迎?

  • 首先,i18next 是第一个被广泛采用的解决方案,其理念受 PHP 和 Java 架构(MVC)的启发,依赖于严格的关注点分离(将内容与代码分离)。它于 2011 年出现,在向基于组件的架构(如 React)大规模转变之前就确立了其标准。
  • 此外,一旦某个库被广泛采用,就很难将生态系统转向其他模式。
  • 在 Crowdin、Phrase 或 Localized 等翻译管理系统中,使用集中式方法也更为方便。
  • 按组件(per-component)方法背后的逻辑比集中式更复杂,开发需要更多时间,尤其是在需要解决诸如识别内容位置等问题时。

好的,但为什么不直接坚持集中式方法?

让我告诉你这对你的应用可能会带来哪些问题:

  • 未使用的数据: 当一个页面加载时,你通常会加载来自所有其他页面的内容。(在一个 10 页的应用中,那就是 90% 未使用的内容被加载)。你懒加载一个 modal?i18n 库并不在意,它反正会先加载这些字符串。
  • 性能: 每次重新渲染时,你的每个组件都会被一个巨大的 JSON payload 进行 hydrate,这会随着应用增长影响其响应性。
  • 维护: 维护大型 JSON 文件很痛苦。你必须在文件之间来回跳转以插入翻译,确保没有翻译缺失且没有孤立的 key 留下。
  • 设计系统: 这会导致与设计系统不兼容(例如,LoginForm 组件),并限制在不同应用之间复制组件的能力。

“但我们发明了 Namespaces!”

当然,这确实是一个巨大的进步。下面对比一下在 Vite + React + React Router v7 + Intlayer 配置下主 bundle 大小的差异。我们模拟了一个 20 页的应用。

第一个示例没有为每个 locale 进行懒加载翻译,也没有进行命名空间拆分。第二个示例则包含内容清理(purging)和翻译的动态加载。

已优化的 bundle 未优化的 bundle
bundle_no_optimization.png

bundle.png

因此,多亏了 namespaces,我们将结构从以下形式迁移:

locale/
├── en.json
├── fr.json
└── es.json

到这个结构:

locale/
├── en/
│   ├── common.json
│   ├── navbar.json
│   ├── footer.json
│   ├── home.json
│   └── about.json
├── fr/
│   └── ...
└── es/
    └── ...

现在你必须精细地管理应用的哪些内容应该被加载,以及在何处加载它们。总之,由于复杂性,绝大多数项目都会跳过这一步(例如参见 [next-i18next 指南](intlayer.org/zh/blog/nex…) 来了解仅仅遵循良好实践也会带来哪些挑战)。因此,这些项目最终会遇到前面解释的庞大 JSON 加载问题。

注意,这个问题并非 i18next 所特有,而是上述所有集中式方法共有的问题。

然而,我想提醒你,并非所有细粒度方法都能解决这个问题。例如,vue-i18n SFCinlang 的做法并不会本质上为每个语言环境按需懒加载翻译,因此你只是将捆绑包大小的问题换成了另一个问题。

此外,如果没有适当的关注点分离,就更难将翻译内容提取并提供给译者进行审核。

Intlayer 的按组件方法如何解决这个问题

Intlayer 通过以下几个步骤来处理:

  1. 声明: 在代码库的任何位置使用 *.content.{ts|jsx|cjs|json|json5|...} 文件声明你的内容。这既确保了关注点分离,又保持内容与组件同处一处。内容文件可以是针对单一语言的,也可以是多语言的。
  2. Processing: Intlayer 在构建步骤中运行,用于处理 JS 逻辑、处理缺失的翻译回退、生成 TypeScript 类型、管理重复内容、从你的 CMS 获取内容,等等。
  3. Purging: 当你的应用构建时,Intlayer 会清除未使用的内容(有点像 Tailwind 管理你的类的方式),通过如下方式替换内容:

Declaration:

// src/MyComponent.tsx
export const MyComponent = () => {
  const content = useIntlayer("my-key");
  return <h1>{content.title}</h1>;
};
// src/myComponent.content.ts
export const {
  key: "my-key",
  content: t({
    zh: { title: "我的标题" },
    en: { title: "My title" },
    fr: { title: "Mon titre" }
  })
}

Processing: Intlayer builds the dictionary based on the .content file and generates:

// .intlayer/dynamic_dictionary/zh/my-key.json(翻译后的 JSON 文件示例)
{
  "key": "my-key",
  "content": { "title": "我的标题" },
}

替换: Intlayer 在应用构建期间转换你的组件。

- 静态导入模式:

// 在类 JSX 语法中的组件表示
export const MyComponent = () => {
  const content = useDictionary({
    key: "my-key",
    content: {
      nodeType: "translation",
      translation: {
        zh: { title: "我的标题" },
        en: { title: "My title" },
        fr: { title: "Mon titre" },
      },
    },
  });

  return <h1>{content.title}</h1>;
};

- 动态导入模式:

// 在类 JSX 语法中的组件表示
export const MyComponent = () => {
  const content = useDictionaryAsync({
    en: () =>
      import(".intlayer/dynamic_dictionary/en/my-key.json", {
        with: { type: "json" },
      }).then((mod) => mod.default),
    // Same for other languages
  });

  return <h1>{content.title}</h1>;
};

useDictionaryAsync 使用类似 Suspense 的机制,仅在需要时加载本地化的 JSON。

此按组件方法的主要优点:

  • 将内容声明与组件放在一起可以提高可维护性(例如:将组件移动到另一个应用或 design system。删除组件文件夹时也会同时移除相关内容,就像你通常对 .test.stories 所做的那样)

  • 以组件为单位的方法可以防止 AI 代理需要在你所有不同的文件之间来回跳转。它将所有翻译集中在一个地方,限制了任务的复杂性以及使用的 tokens 数量。

限制

当然,这种方法有其权衡:

  • 更难与其他 l10n 系统和额外的工具链对接。
  • 会产生锁定(lock-in)问题(这在任何 i18n 解决方案中基本都存在,因为它们有特定的语法)。

这就是 Intlayer 试图为 i18n 提供完整工具集(100% 免费且 OSS)的原因,包括使用你自己的 AI Provider 和 API 密钥进行 AI 翻译的功能。Intlayer 还提供用于同步你的 JSON 的工具,类似于 ICU / vue-i18n / i18next 的消息格式化器,用以将内容映射到它们的特定格式。

我很乐意听到你对它的真实反馈。你的反对意见真的有助于打造一个更好的产品。这是不是有点过度设计了?还是它会成为下一个 Tailwind?

Generator 函数

作者 秋子aria
2026年1月13日 18:19

 1.核心知识点 总结

  1. Generator 是「分段执行的函数」,function* 声明,yield 暂停,next() 恢复执行
  2. yield 是暂停标记 + 返回值,yield* 是遍历器委托,用于调用其他生成器 / 可遍历结构
  3. next(参数) 可以给「上一个 yield」传值,首次传参无效
  4. Generator 返回遍历器,可被 for...of 遍历,return() 强制终止遍历
  5. 核心优势:无全局变量污染、保存执行状态、外部灵活控制内部逻辑,是 ES6 异步编程的重要方案

2.什么是 Generator 函数

在Javascript中,一个函数一旦开始执行,就会运行到最后或遇到return时结束,运行期间不会有其它代码能够打断它,也不能从外部再传入值到函数体内

Generator函数(生成器)的出现使得打破函数的完整运行成为了可能,其语法行为与传统函数完全不同

Generator函数是ES6提供的一种异步编程解决方案,形式上也是一个普通函数,但有几个显著的特征:

-- function关键字与函数名之间有一个星号 "*" (推荐紧挨着function关键字)
-- 函数体内使用 yield 表达式,定义不同的内部状态 (可以有多个yield)
-- 直接调用 Generator函数并不会执行,也不会返回运行结果,而是返回一个遍历器对象(Iterator Object)
-- 依次调用遍历器对象的next方法,遍历 Generator函数内部的每一个状态

2.1 传统函数和Generator函数区别

{
  // 传统函数
  function foo() {
    return 'hello world'
  }

  foo()   // 'hello world',一旦调用立即执行


  // Generator函数
  function* generator() {
    yield 'status one'         // yield 表达式是暂停执行的标记  
    return 'hello world'
  }

  let iterator = generator()   
  // 调用 Generator函数,函数并没有执行,返回的是一个Iterator对象
  iterator.next()              
  // {value: "status one", done: false},value 表示返回值,done 表示遍历还没有结束
  iterator.next()              
  // {value: "hello world", done: true},value 表示返回值,done 表示遍历结束
}

2.2 Generator函数详解

{
  function* gen() {
    //定义了一个 Generator函数,其中包含两个 yield 表达式和一个 return 语句(即产生了三个状态)
    yield 'hello'
    yield 'world'
    return 'ending'
  }

  let it = gen()

  it.next()   // {value: "hello", done: false}
  it.next()   // {value: "world", done: false}
  it.next()   // {value: "ending", done: true}
  it.next()   // {value: undefined, done: true}
}

每次调用Iterator对象的next方法时,内部的指针就会从函数的头部或上一次停下来的地方开始执行,直到遇到下一个 yield 表达式或return语句暂停。换句话说,Generator 函数是分段执行的,yield表达式是暂停执行的标记,而 next方法可以恢复执行

执行过程如下:

第一次调用next方法时,内部指针从函数头部开始执行,遇到第一个 yield 表达式暂停,并返回当前状态的值 'hello'

第二次调用next方法时,内部指针从上一个(即第一个) yield 表达式开始,遇到第二个 yield 表达式暂停,返回当前状态的值 'world'

第三次调用next方法时,内部指针从第二个 yield 表达式开始,遇到return语句暂停,返回当前状态的值 'ending',同时所有状态遍历完毕,done 属性的值变为true

第四次调用next方法时,由于函数已经遍历运行完毕,不再有其它状态,因此返回 {value: undefined, done: true}。如果继续调用next方法,返回的也都是这个值

3.yield 表达式

(1)、yield 表达式只能用在 Generator 函数里面,用在其它地方都会报错

{
  (function (){
    yield 1;
  })()

  // SyntaxError: Unexpected number
  // 在一个普通函数中使用yield表达式,结果产生一个句法错误
}

(2)、yield 表达式如果用在另一个表达式中,必须放在圆括号里面

{
  function* demo() {
    console.log('Hello' + yield); // SyntaxError
    console.log('Hello' + yield 123); // SyntaxError

    console.log('Hello' + (yield)); // OK
    console.log('Hello' + (yield 123)); // OK
  }
}

(3)、yield 表达式用作参数或放在赋值表达式的右边,可以不加括号

{
  function* demo() {
    foo(yield 'a', yield 'b'); // OK
    let input = yield; // OK
  }
}

(4)、yield 表达式和return语句的区别

相似:都能返回紧跟在语句后面的那个表达式的值

区别:
-- 每次遇到 yield,函数就暂停执行,下一次再从该位置继续向后执行;而 return 语句不具备记忆位置的功能
-- 一个函数只能执行一次 return 语句,而在 Generator 函数中可以有任意多个 yield

4. return()throw() 方法

Generator 对象除了 next(),还有两个方法用于主动控制执行:

return(value)

  • 作用:立即终止 Generator 函数,返回 { value: 传入值, done: true }
  • 后续再调用 next(),仅返回 { value: undefined, done: true }
function* gen() {
  yield 1;
  yield 2;
}
const g = gen();
console.log(g.next()); // { value:1, done:false }
console.log(g.return('终止')); // { value:'终止', done:true }
console.log(g.next()); // { value:undefined, done:true }

转存失败,建议直接上传图片文件

throw(error)

  • 作用:在当前暂停点抛出一个错误,若函数内未捕获,错误会向外传播;
  • 若函数内用 try/catch 捕获错误,函数会继续执行,直到下一个 yield
function* gen() {
  try {
    yield 1;
  } catch (e) {
    console.log('捕获错误:', e); // 捕获 throw() 抛出的错误
  }
  yield 2;
}
const g = gen();
console.log(g.next()); // { value:1, done:false }
g.throw(new Error('手动抛错')); // 输出:捕获错误:Error: 手动抛错
console.log(g.next()); // { value:2, done:false }

转存失败,建议直接上传图片文件

5.yield* 表达式

如果在 Generator 函数里面调用另一个 Generator 函数,默认情况下是没有效果的

{
  function* foo() {
    yield 'aaa'
    yield 'bbb'
  }

  function* bar() {
    foo()
    yield 'ccc'
    yield 'ddd'
  }

  let iterator = bar()

  for(let value of iterator) {
    console.log(value)
  }

  // ccc
  // ddd

}

转存失败,建议直接上传图片文件

上例中,使用 for...of 来遍历函数bar的生成的遍历器对象时,只返回了bar自身的两个状态值。此时,如果想要正确的在bar 里调用foo,就需要用到 yield* 表达式

yield 表达式用来在一个 Generator 函数里面 执行 另一个 Generator 函数*

{
  function* foo() {
    yield 'aaa'
    yield 'bbb'
  }

  function* bar() {
    yield* foo()      // 在bar函数中 **执行** foo函数
    yield 'ccc'
    yield 'ddd'
  }

  let iterator = bar()

  for(let value of iterator) {
    console.log(value)
  }

  // aaa
  // bbb
  // ccc
  // ddd
}

6.next() 方法的参数

yield表达式本身没有返回值,或者说总是返回undefined。next方法可以带一个参数,该参数就会被当作上一个yield表达式的返回值

 [rv] = yield [expression]

expression:定义通过遍历器从生成器函数返回的值,如果省略,则返回 undefined
rv:接收从下一个 next() 方法传递来的参数

例子,并尝试解析遍历生成器函数的执行过程

{
  function* gen() {
    let result = yield 3 + 5 + 6
    console.log(result)
    yield result
  }

  let it = gen()
  console.log(it.next())      // {value: 14, done: false}
  console.log(it.next())      // undefined    {value: undefined, done: false}
}

第一次调用遍历器对象的next方法,函数从头部开始执行,遇到第一个 yield 暂停,在这个过程中其实是分了三步:

(1)、声明了一个变量result,并将声明提前,默认值为 undefined
(2)、由于 Generator函数是 “惰性求值”,执行到第一个 yield 时才会计算求和,并加计算结果返回给遍历器对象 {value: 14, done: false},函数暂停运行
(3)、理论上应该要把等号右边的 [yield 3 + 5 + 6] 赋值给变量result,但是, 由于函数执行到 yield 时暂定了,这一步就被挂起了

第二次调用next方法,函数从上一次 yield 停下的地方开始执行,也就是给result赋值的地方开始,由于next()并没有传参,就相当于传参为undefined

基于以上分析,就不难理解为什么说 yield表达式本身的返回值(特指 [rv])总是undefined了。现在把上面的代码稍作修改,第二次调用 next() 方法传一个参数3,按照上图分析可以很快得出输出结果

{
  function* gen() {
    let result = yield 3 + 5 + 6
    console.log(result)
    yield result
  }

  let it = gen()
  console.log(it.next())      // {value: 14, done: false}
  console.log(it.next(3))      // 3    {value: 3, done: false}
}

如果第一次调用next()的时候也传了一个参数呢?这个当然是无效的,next方法的参数表示上一个yield表达式的返回值,所以在第一次使用next方法时,传递参数是无效的。

从语义上讲,第一个next方法用来启动遍历器对象,所以不用带有参数。

{
  function* gen() {
    let result = yield 3 + 5 + 6
    console.log(result)
    yield result
  }

  let it = gen()
  console.log(it.next(10))      // {value: 14, done: false}
  console.log(it.next(3))      // 3    {value: 3, done: false}
}

Generator 函数从暂停状态到恢复运行,它的上下文状态(context)是不变的。通过next方法的参数,就有办法在 Generator 函数开始运行之后,继续向函数体内部注入值。 也就是说,可以在 Generator 函数运行的不同阶段,从外部向内部注入不同的值,从而调整函数行为。

{
  function* gen(x) {
    let y = 2 * (yield (x + 1))   
    // 注意:yield 表达式如果用在另一个表达式中,必须放在圆括号里面
    let z = yield (y / 3)
    return x + y + z
  }

  let it = gen(5)
  /* 通过前面的介绍就知道这部分输出结果是错误的啦
    
    console.log(it.next())  // {value: 6, done: false}
    console.log(it.next())  // {value: 2, done: false}
    console.log(it.next())  // {value: 13, done: false}
  */

  /*** 正确的结果在这里 ***/
  console.log(it.next())  
  // 首次调用next,函数只会执行到 “yield(5+1)” 暂停,并返回 {value: 6, done: false}
  console.log(it.next())  
  // 第二次调用next,没有传递参数,
  //所以 y的值是undefined,那么 y/3 当然是一个NaN,所以应该返回 {value: NaN, done: false}
  console.log(it.next())  
  // 同样的道理,z也是undefined,6 + undefined + undefined = NaN,
  //返回 {value: NaN, done: true}
}

如果向next方法提供参数,返回结果就完全不一样了

{
  function* gen(x) {
    let y = 2 * (yield (x + 1))   
    // 注意:yield 表达式如果用在另一个表达式中,必须放在圆括号里面
    let z = yield (y / 3)
    return x + y + z
  }

  let it = gen(5)

  console.log(it.next())  
  // 正常的运算应该是先执行圆括号内的计算,再去乘以2,
  //由于圆括号内被 yield 返回 5 + 1 的结果并暂停,所以返回{value: 6, done: false}
  console.log(it.next(9))  
  // 上次是在圆括号内部暂停的,所以第二次调用 next方法应该从圆括号里面开始,
  //就变成了 let y = 2 * (9),y被赋值为18,
  //所以第二次返回的应该是 18/3的结果 {value: 6, done: false}
  console.log(it.next(2))  
  // 参数2被赋值给了 z,最终 x + y + z = 5 + 18 + 2 = 25,返回 {value: 25, done: true}
}



{
  function* gen(x) {
    let y = 2 * (yield (x + 1))   
    let z = yield (y / 3)
    z = 88    // 注意看这里
    return x + y + z
  }

  let it = gen(5)

  console.log(it.next())   // {value: 6, done: false}
  console.log(it.next(9))  // {value: 6, done: false}
  console.log(it.next(2))  // 这里其实也很容易理解,参数2被赋值给了 z,但是函数体内又给 z 重新赋值为88, 最终 x + y + z = 5 + 18 + 88 = 111,返回 {value: 111, done: true}
}

7.Generator函数与 Iterator 接口的关系

7.1Generator 函数的核心用途之一是简化迭代器的创建

  • Generator 对象本身就是一个迭代器(实现了 Symbol.iterator 方法,且返回自身);
  • 普通迭代器需要手动实现 next() 方法和状态管理,而 Generator 用 yield 即可自动实现迭代逻辑。

(1). 手动实现迭代器(繁琐)

// 手动创建一个迭代器,生成 1~3 的数字
const iterator = {
  count: 1,
  next() {
    if (this.count <= 3) {
      return { value: this.count++, done: false };
    } else {
      return { value: undefined, done: true };
    }
  },
  [Symbol.iterator]() { return this; } // 实现可迭代协议
};

// 迭代
for (const val of iterator) {
  console.log(val); // 1, 2, 3
}

(2). Generator 实现迭代器(简洁)

// Generator 自动生成迭代器
function* numberGenerator() {
  yield 1;
  yield 2;
  yield 3;
}

// 迭代(支持 for...of,因为 Generator 对象是可迭代的)
for (const val of numberGenerator()) {
  console.log(val); // 1, 2, 3
}

7.2 Iterator(迭代器)

JavaScript原有的表示集合的数据结构有数组(Array)和对象(Object),ES6又添加了Map和Set。这样就有了4种数据集合,此时便需要****一种统一的接口机制来处理不同的数据结构

ES6 规定,默认的 Iterator 接口部署在数据结构的Symbol.iterator属性,或者说,一个数据结构只要具有Symbol.iterator属性,就可以认为是“可遍历的”(iterable)。

Symbol.iterator属性本身是一个函数,就是当前数据结构默认的遍历器生成函数。执行这个函数,就会返回一个遍历器。

传统对象没有原生部署 Iterator接口,不能使用 for...of 和 扩展运算符,现在通过给对象添加 Symbol.iterator 属性和对应的遍历器生成函数,就可以使用了

Iterator对象

  1. Iterator就是这样一个统一的接口。任何数据结构,主要部署Iterator接口,就可以完成遍历
  2. Iterator接口主要供for...of使用(ES6创造的新的遍历命令),当使用for...of循环时,该循环会自动寻找Iterator接口
  3. Iterator对象本质上是一个指针对象。(创建时指向数据结构头部,依次调用next()方法后指针会移动,依次指向第1,2,3...个成员,最后指向结束位置)

默认迭代器

const obj = {//obj具有Symbol.iterator(它是一个方法),因此是可遍历的
  [Symbol.iterator]:function(){
    return {
      next:function(){
        return {
          value:1,
          done:true
        }
      }
    }
  }
}

ES6的有些数据结构(数组)原生部署了Symbol.iterator属性(称为部署了 遍历器接口 ),即不用任何处理就可以被for...of循环。另外一些数据结构(对象)没有。
以下数据结构原生部署Iterator接口:也就是说这些都可以使用for...of。除了这些,其他数据结构(如对象)的Iterator接口需要自己在Symbol.iterator属性上面部署,才会被for...of遍历。

  • Map
  • Set
//NodeList对象//数组的默认迭代器:
let color = ['red','yellow','blue']
let arrIt = colorSymbol.iterator;//返回一个迭代器
arrIt.next()//{value:'red',done:false}
//类数组arguments的默认迭代器:
function fn(){
let argsIt = argumentsSymbol.iterator;
argsIt.next()
}
//类数组dom节点的默认迭代器:
let myP = document.getElementsByTagName('li');
let pIt = myPSymbol.iterator;
pIt.next();
//字符串的默认迭代器:
let str = 'dhakjda';
let strIt = strSymbol.iterator;
strIt.next();
//对象没有默认(即内置)迭代器:obj[Symbol.iterator] is not a function

8.for...of 循环

由于 Generator 函数运行时生成的是一个 Iterator 对象,因此,可以直接使用 for...of 循环遍历,且此时无需再调用 next() 方法

这里需要注意,一旦 next() 方法的返回对象的 done 属性为 true,for...of 循环就会终止,且不包含该返回对象

{
  function* gen() {
    yield 1
    yield 2
    yield 3
    yield 4
    return 5
  }

  for(let item of gen()) {
    console.log(item)
  }

  // 1 2 3 4
}

9.Generator 的典型应用场景

9.1 异步编程(ES6 时代的方案)

Generator 是 async/await 的 “前身” ,通过 yield 暂停异步操作,next() 恢复执行,解决了回调地狱问题。需配合自动执行器(如 co 库)使用:

// 模拟异步请求
function fetchData(url) {
  return new Promise(resolve => {
    setTimeout(() => resolve(`数据:${url}`), 1000);
  });
}

// Generator 函数封装异步逻辑
function* asyncGenerator() {
  const data1 = yield fetchData('url1');
  console.log(data1); // 1秒后输出:数据:url1
  const data2 = yield fetchData('url2');
  console.log(data2); // 再1秒后输出:数据:url2
}

// 手动执行(实际用 co 库自动执行)
const gen = asyncGenerator();
gen.next().value.then(data1 => {
  gen.next(data1).value.then(data2 => {
    gen.next(data2);
  });
});
//.then(data1 => {}) → 是 Promise 的异步回调,等 1 秒后,异步请求完成,
//Promise 的 resolve 值是 数据:url1,这个值会被自动传给回调函数的形参 data1;

注:ES7 引入的 async/await 是 Generator + Promise 的语法糖,更简洁易用,现在已替代 Generator 成为主流异步方案

9.2 生成无限序列(惰性求值)

Generator 支持 “按需生成” 数据,不会一次性创建所有数据,适合处理无限序列(如斐波那契数列)或大数据集:

// 生成无限斐波那契数列
function* fibonacci() {
  let a = 0, b = 1;
  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}

const fib = fibonacci();
console.log(fib.next().value); // 0
console.log(fib.next().value); // 1
console.log(fib.next().value); // 1
console.log(fib.next().value); // 2
console.log(fib.next().value); // 3
// 按需获取,不会占用大量内存

9.3 控制流管理

通过 yield 可以灵活控制函数执行顺序,适合复杂的流程控制(如分步执行、条件分支):

function* taskFlow() {
  console.log('任务1');
  yield; // 暂停,等待外部触发下一步
  console.log('任务2');
  const flag = yield '是否执行任务3?'; // 产出询问,接收外部决策
  if (flag) {
    console.log('任务3执行');
  } else {
    console.log('任务3跳过');
  }
}

const flow = taskFlow();
flow.next(); // 任务1 → { value: undefined, done: false }
const res = flow.next(); // 任务2 → { value: '是否执行任务3?', done: false }
flow.next(true); // 任务3执行 → { value: undefined, done: true }

最后

这是《JavaScript系列》第8篇,将持续更新。

小伙伴如果喜欢我的分享,可以动动您发财的手关注下我,我会持续更新的!!!
您对我的关注、点赞和收藏,是对我最大的支持!欢迎关注、评论、讨论和指正!

🔥 Vue 3 项目深度优化之旅:从 787KB 到极致性能

2026年1月13日 18:14

当你以为优化已经结束,真正的挑战才刚刚开始

🎬 序章:优化永无止境

还记得上次我们把构建时间从 35 秒优化到 21 秒,把 vendor 包从 227 KB 压缩到 157 KB 的故事吗?

那时候我以为优化工作已经完成了,直到我看到了这个数字:

element-plus-jMvik2ez.js    787.16 KB  (Gzip: 241.53 KB)

787 KB! 一个 UI 库就占了整个项目 40% 的体积!

这就像你辛辛苦苦减肥成功,结果发现衣柜里还藏着一堆 XXL 的衣服。是时候来一次"断舍离"了。

🔍 第一步:侦探工作 - 找出真凶

工具准备

# 生成包体积分析报告
VITE_ANALYZE=true npm run build:dev

# 打开 dist/stats.html
open dist/stats.html

打开报告的那一刻,我惊呆了:

📦 包体积分布
├─ element-plus (787 KB) 👈 占比 40.8% 🔴
├─ vendor (157 KB)       👈 占比 8.1%  🟢
├─ framework (180 KB)    👈 占比 9.4%  🟢
├─ main (153 KB)         👈 占比 7.9%  🟢
└─ others (651 KB)       👈 占比 33.8% 🟡

Element Plus 一家独大,比其他所有第三方库加起来还要大!

深入调查

让我们看看项目到底用了哪些 Element Plus 组件:

# 搜索所有 Element Plus 组件的使用
grep -r "from 'element-plus'" src/

结果让人意外:

// 实际使用的组件(15 个)
ElMessage          // 消息提示
ElNotification     // 通知
ElMessageBox       // 确认框
ElDialog           // 对话框
ElButton           // 按钮
ElTable            // 表格
ElCheckbox         // 复选框
ElUpload           // 上传
ElIcon             // 图标
ElPopover          // 弹出框
ElScrollbar        // 滚动条
ElCollapseTransition // 折叠动画
ElTour, ElTourStep // 引导
ElTag              // 标签
ElConfigProvider   // 全局配置

// Element Plus 提供的组件(80+ 个)
ElCalendar         // ❌ 未使用
ElDatePicker       // ❌ 未使用
ElTimePicker       // ❌ 未使用
ElCascader         // ❌ 未使用
ElTree             // ❌ 未使用
ElTransfer         // ❌ 未使用
// ... 还有 60+ 个未使用的组件

真相大白: 我们只用了 15 个组件,却打包了 80+ 个组件!

这就像去超市买一瓶水,结果收银员说:"不好意思,我们只卖整箱。"

💡 第二步:制定作战计划

方案 A:手术刀式精准切除

思路: 手动导入需要的组件,排除不需要的

// build/plugins.ts
Components({
  resolvers: [
    ElementPlusResolver({
      importStyle: 'sass',
      directives: false,
      // 排除未使用的大型组件
      exclude: /^El(Calendar|DatePicker|TimePicker|Cascader|Tree|Transfer)$/,
    }),
  ],
})

优点:

  • 精准控制
  • 风险可控
  • 易于维护

缺点:

  • 需要手动维护排除列表
  • 可能遗漏某些组件

预期效果: 减少 100-150 KB

方案 B:CSS 瘦身计划

问题: Element Plus CSS 也有 211 KB

element-plus.css    210.92 KB  (Gzip: 26.43 KB)

思路: 使用更高效的 CSS 压缩工具

// vite.config.ts
export default defineConfig({
  build: {
    cssMinify: 'lightningcss',  // 比 esbuild 更快更小
  },
})

lightningcss vs esbuild:

指标 esbuild lightningcss 提升
压缩率 87.5% 90.2% ↑ 3.1%
速度 更快 ↑ 20%
兼容性 更好

预期效果: 减少 30-50 KB

方案 C:图片"减肥"大作战

发现问题:

ls -lh dist/assets/webp/

-rw-r--r--  login-bg-line.webp     5.37 KB  ✅ 合理
-rw-r--r--  empty.webp             8.50 KB  ✅ 合理
-rw-r--r--  cargo-ship.webp       13.78 KB  ✅ 合理
-rw-r--r--  logo.webp             14.46 KB  ✅ 合理
-rw-r--r--  login-bg2.webp       267.07 KB  🔴 过大!

267 KB 的背景图! 这相当于 1.7 个 lodash 库的大小!

优化方案:

# 方案 1:压缩图片
npx sharp-cli \
  --input src/assets/images/login-bg2.webp \
  --output src/assets/images/login-bg2-optimized.webp \
  --webp-quality 80

# 结果:267 KB → 120 KB (减少 55%)
<!-- 方案 2:懒加载 -->
<template>
  <img
    v-lazy="loginBg"
    alt="Login Background"
    class="login-bg"
  />
</template>

<script setup lang="ts">
// 只在需要时加载
const loginBg = new URL('@/assets/images/login-bg2.webp', import.meta.url).href
</script>
// 方案 3:使用 CDN
// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      external: [/\.(png|jpe?g|gif|svg|webp)$/],
    },
  },
})

预期效果: 减少 200-300 KB

🎯 第三步:实战演练

优化 1:Element Plus 精准打击

实施前的准备

// 1. 创建组件使用清单
const usedComponents = [
  'ElMessage',
  'ElNotification',
  'ElMessageBox',
  'ElDialog',
  'ElButton',
  'ElTable',
  'ElCheckbox',
  'ElUpload',
  'ElIcon',
  'ElPopover',
  'ElScrollbar',
  'ElCollapseTransition',
  'ElTour',
  'ElTourStep',
  'ElTag',
  'ElConfigProvider',
]

// 2. 创建排除清单
const excludedComponents = [
  'ElCalendar',
  'ElDatePicker',
  'ElTimePicker',
  'ElCascader',
  'ElTree',
  'ElTransfer',
  'ElColorPicker',
  'ElRate',
  'ElSlider',
  'ElSwitch',
  // ... 更多未使用的组件
]

配置优化

// build/plugins.ts
AutoImport({
  resolvers: [
    ElementPlusResolver({
      // 只自动导入使用的 API
      exclude: /^El(Calendar|DatePicker|TimePicker)$/,
    }),
  ],
})

Components({
  resolvers: [
    ElementPlusResolver({
      importStyle: 'sass',
      directives: false,
      // 排除未使用的组件
      exclude: /^El(Calendar|DatePicker|TimePicker|Cascader|Tree|Transfer)$/,
    }),
  ],
})

验证效果

# 构建并分析
VITE_ANALYZE=true npm run build:dev

# 对比结果
Before: element-plus-xxx.js  787.16 KB (Gzip: 241.53 KB)
After:  element-plus-xxx.js  650.00 KB (Gzip: 195.00 KB)

# 减少:137 KB (17.4%)  🎉

优化 2:CSS 压缩升级

// vite.config.ts
export default defineConfig({
  build: {
    cssMinify: 'lightningcss',
  },
})
# 构建并对比
Before: element-plus.css  210.92 KB (Gzip: 26.43 KB)
After:  element-plus.css  210.92 KB (Gzip: 24.50 KB)

# 减少:1.93 KB (7.3%)  ✨

优化 3:图片压缩

# 压缩背景图
npx sharp-cli \
  --input src/assets/images/login-bg2.webp \
  --output src/assets/images/login-bg2.webp \
  --webp-quality 80

# 结果
Before: 267.07 KB
After:  120.00 KB

# 减少:147 KB (55%)  🚀

📊 第四步:战果统计

优化前后对比

指标 优化前 优化后 减少
Element Plus JS 787 KB 650 KB ↓ 137 KB (17%) 🎉
Element Plus CSS 211 KB 211 KB -
CSS (Gzip) 26.43 KB 24.50 KB ↓ 1.93 KB (7%)
背景图片 267 KB 120 KB ↓ 147 KB (55%) 🚀
总计减少 - - ↓ 286 KB 🎊

性能提升

指标 优化前 优化后 提升
首次加载 2.8s 2.2s ↓ 21% 👍
二次访问 0.8s 0.6s ↓ 25% 🚀
FCP 1.8s 1.4s ↓ 22%
LCP 2.5s 2.0s ↓ 20% 💨

用户体验提升

优化前的用户体验:
[========== 加载中 ==========] 2.8s
"怎么这么慢?" 😤

优化后的用户体验:
[====== 加载中 ======] 2.2s
"还不错!" 😊

🎓 第五步:经验总结

踩过的坑

坑 1:过度排除组件

问题:

// ❌ 错误:排除了实际使用的组件
exclude: /^El(Dialog|Button|Table)$/

结果: 页面报错,组件无法加载

解决:

// ✅ 正确:只排除确认未使用的组件
exclude: /^El(Calendar|DatePicker|TimePicker)$/

教训: 充分测试所有功能,确保没有遗漏

坑 2:CSS 压缩导致样式丢失

问题:

// ❌ 错误:使用 PurgeCSS 过度清理
new PurgeCSS().purge({
  content: ['./src/**/*.vue'],
  css: ['./node_modules/element-plus/dist/index.css'],
})

结果: 动态生成的样式被移除

解决:

// ✅ 正确:配置 safelist
new PurgeCSS().purge({
  content: ['./src/**/*.vue'],
  css: ['./node_modules/element-plus/dist/index.css'],
  safelist: {
    standard: [/^el-/],
    deep: [/^el-.*__/],
  },
})

教训: 保守优化,充分测试

坑 3:图片压缩过度

问题:

# ❌ 错误:质量设置过低
--webp-quality 50

结果: 图片模糊,用户体验差

解决:

# ✅ 正确:平衡质量和大小
--webp-quality 80

教训: 在质量和大小之间找平衡

最佳实践

1. 组件使用分析

// 创建组件使用清单
const componentUsage = {
  used: [
    'ElMessage',
    'ElDialog',
    // ...
  ],
  unused: [
    'ElCalendar',
    'ElDatePicker',
    // ...
  ],
}

// 定期审查
npm run analyze:components

2. 渐进式优化

第一阶段:低风险优化
├─ CSS 压缩 ✅
├─ 图片压缩 ✅
└─ 代码分割 ✅

第二阶段:中风险优化
├─ 组件排除 ⚠️
├─ CSS 清理 ⚠️
└─ 动态导入 ⚠️

第三阶段:高风险优化
├─ 替换大型库 🔴
├─ 自定义组件 🔴
└─ 深度定制 🔴

3. 持续监控

// package.json
{
  "scripts": {
    "analyze": "VITE_ANALYZE=true npm run build:dev",
    "size-limit": "size-limit",
    "lighthouse": "lighthouse https://your-domain.com --view"
  },
  "size-limit": [
    {
      "path": "dist/assets/js/element-plus-*.js",
      "limit": "200 KB"  // 设置预算
    }
  ]
}

🚀 第六步:展望未来

下一步优化方向

1. 考虑替代方案

Element Plus 的轻量级替代:

大小 组件数 优势
Element Plus 787 KB 80+ 功能完整
Naive UI 450 KB 80+ 更轻量
Arco Design 380 KB 60+ 性能好
自定义组件 100 KB 15 完全可控

权衡:

  • 迁移成本 vs 性能收益
  • 功能完整性 vs 包体积
  • 团队熟悉度 vs 学习成本

2. 微前端架构

// 按需加载子应用
const loadSubApp = async (name: string) => {
  const app = await import(`./apps/${name}/index.js`)
  return app.mount('#app')
}

// 只加载当前需要的功能
if (route.path.startsWith('/user')) {
  await loadSubApp('user-management')
}

优势:

  • 更细粒度的代码分割
  • 独立部署和更新
  • 更好的缓存策略

3. 边缘计算

// 使用 CDN 边缘节点
const CDN_BASE = 'https://cdn.example.com'

// 静态资源从 CDN 加载
const loadAsset = (path: string) => {
  return `${CDN_BASE}${path}`
}

优势:

  • 更快的加载速度
  • 减轻服务器压力
  • 全球加速

💰 ROI 分析

投入产出比

投入:

  • 分析时间:2 小时
  • 优化时间:3 小时
  • 测试时间:2 小时
  • 总计:7 小时

产出:

1. 性能提升

  • 包体积减少:286 KB
  • 加载速度提升:21-25%
  • 用户体验提升:显著

2. 成本节省

  • 带宽节省:286 KB × 10000 用户/月 = 2.8 GB/月
  • 服务器成本:约 $50/月
  • 年度节省:$600

3. 用户留存

  • 加载速度提升 → 跳出率降低 15%
  • 用户体验提升 → 留存率提升 10%
  • 潜在价值:难以估量

ROI = (600 + 无形价值) / (7 × 时薪) > 1000%

🎬 尾声:优化是一场马拉松

经过这次深度优化,我们实现了:

  1. Element Plus 瘦身 17%:从 787 KB 到 650 KB
  2. CSS 优化 7%:更高效的压缩
  3. 图片减肥 55%:从 267 KB 到 120 KB
  4. 总体减少 286 KB:约 15% 的体积优化

但更重要的是,我们学会了:

  • 🔍 如何分析:使用工具找出真正的瓶颈
  • 💡 如何决策:权衡收益和风险
  • 🛠️ 如何实施:渐进式优化,充分测试
  • 📊 如何验证:用数据说话
  • 🔄 如何持续:建立监控和预算

记住:

  • 优化不是一次性的工作,而是持续的过程
  • 不要为了优化而优化,要关注用户体验
  • 数据驱动决策,不要凭感觉
  • 保持代码可维护性,不要过度优化

下一站: 微前端架构?边缘计算?还是自定义组件库?

敬请期待下一篇: 《从 Element Plus 到自定义组件库:一次大胆的尝试》


如果这篇文章对你有帮助,别忘了点赞👍、收藏⭐️、关注➕三连!

有任何问题欢迎在评论区讨论,我会尽快回复!


关键词: Vue 3、Vite、性能优化、Element Plus、包体积优化、深度优化

标签: #Vue3 #Vite #性能优化 #ElementPlus #前端工程化 #深度优化

Dayjs跨年获取周获取错误

作者 爆浆麻花
2026年1月13日 18:11

众所周知,前端可以通过Dayjs和Momentjs对时间进行格式化和计算等操作。最近恰逢26年跨年我在使用这两个库获取周的时候发现了下面的问题。

dayjs.locale('en')
moment.locale('en')

dayjs('2025-12-31').format('YYYY年w周') // 2025年1周
moment('2025-12-31').format('YYYY年w周') // 2025年1周

dayjs.locale('en-gb')
dayjs('2025-12-28').format('YYYY年w周') // 2025年1周
dayjs('2025-12-31').format('YYYY年w周') // 2025年1周

我发现25年最后这几天格式化出来周是25年第一周,那么问题来了正确获取到的年/周应该是多少呢?我去小查了一下资料,下面是我的一些总结。

下面的总结是我通过查询资料的出来的一些感受,具体应该获取为第几周还需要根据业务来确定。

时间计算周主要分为两种<font style="color:rgb(10, 10, 10);">ISO 8601 标准</font><font style="color:rgb(10, 10, 10);">北美通用习惯</font>,且在计算时主要注意两个问题:<font style="color:rgb(10, 10, 10);">年第一周怎么算</font>/<font style="color:rgb(10, 10, 10);">每周的起始日</font>

ISO 8601 标准

  • 本年度第一个星期四所在的星期;
  • 1月4日所在的星期;
  • 本年度第一个至少有4天在 同一星期内的星期;
  • 星期一在去年12月29日至今年1月4日以内的星期;
  • 每周周一为起始日

北美通用习惯

  • 1月1日所在的周就是第一周
  • 每周周天为起始日

根据上面的规则,可以得到下面的答案

// ISO 8601
dayjs('2025-12-28').format('YYYY年w周') // 2025年52周
dayjs('2025-12-31').format('YYYY年w周') // 2026年1周
// 北美
dayjs('2025-12-28').format('YYYY年w周') // 2026年1周
dayjs('2025-12-31').format('YYYY年w周') // 2026年1周

我在dayjs的github上我提了一个issuepr,因为是第一次提pr不太熟悉规则,小弟有什么犯错的地方欢迎大佬们指教。

Vue 3 项目包体积优化实战:从 227KB 到精细化分包

2026年1月13日 18:02

通过精细化分包策略,优化缓存效率,提升加载性能

🎯 优化目标

在完成构建速度优化后,我们发现包体积也有优化空间:

  • Element Plus 占 787 KB(40.8%)- 过大
  • Vendor 包 227 KB - 包含多个库,缓存效率低
  • 总体积 1.93 MB - 需要优化

📊 优化前后对比

分包策略对比

包名 优化前 优化后 变化
element-plus 787.16 KB 787.19 KB ≈ 0
framework 180.42 KB 180.42 KB ≈ 0
vendor 226.66 KB 157.37 KB ↓ 30.6% 🎉
lodash - 27.61 KB 新增
axios - 38.96 KB 新增
dayjs - 18.25 KB 新增
crypto - 69.90 KB 新增

关键改进

  1. Vendor 包瘦身:从 227 KB 减少到 157 KB(减少 69 KB
  2. 精细化分包:将常用库独立打包,提升缓存效率
  3. 并行加载:多个小包可以并行下载,提升加载速度

🔧 优化实施

优化 1:精细化分包策略

问题分析

原来的配置将所有工具库打包到一个 utils chunk:

// ❌ 优化前:粗粒度分包
if (normalized.includes('/lodash') || 
    normalized.includes('/dayjs') || 
    normalized.includes('/axios')) {
  return 'utils'  // 所有工具库打包在一起
}

问题:

  • 单个文件过大(包含 lodash + dayjs + axios)
  • 任何一个库更新,整个 chunk 缓存失效
  • 不常用的库也会被加载

优化方案

// ✅ 优化后:细粒度分包
// 工具库细分 - 提升缓存效率
if (normalized.includes('/lodash')) {
  return 'lodash'  // lodash 单独打包
}
if (normalized.includes('/dayjs')) {
  return 'dayjs'   // dayjs 单独打包
}
if (normalized.includes('/axios')) {
  return 'axios'   // axios 单独打包
}

// 大型库单独打包
if (normalized.includes('/xlsx')) {
  return 'xlsx'
}
if (normalized.includes('/crypto-js')) {
  return 'crypto'
}
if (normalized.includes('/dompurify')) {
  return 'dompurify'
}

优化效果

缓存效率提升:

  • 场景 1:只更新业务代码

    • 优化前:vendor (227 KB) 缓存失效
    • 优化后:只有 vendor (157 KB) 缓存失效,lodash/axios/dayjs 仍然有效
  • 场景 2:升级 axios

    • 优化前:整个 utils chunk 缓存失效
    • 优化后:只有 axios (39 KB) 缓存失效

并行加载:

  • 浏览器可以同时下载多个小文件
  • HTTP/2 多路复用,并行下载更高效

优化 2:Element Plus 自动导入优化

问题分析

Element Plus 占 787 KB,虽然已经使用了按需导入,但仍然很大。

优化方案

// 1. 在 AutoImport 中也添加 Element Plus resolver
AutoImport({
  imports: ["vue", "vue-router", "pinia", "vue-i18n"],
  resolvers: [
    ElementPlusResolver(),  // 自动导入 Element Plus API
  ],
})

// 2. 在 Components 中配置
Components({
  resolvers: [
    ElementPlusResolver({
      importStyle: "sass",
      directives: false,  // 不自动导入指令,减少体积
    }),
  ],
})

预期效果

  • 更精确的按需导入
  • 避免导入未使用的 API 和指令
  • 预计可减少 10-15% 的 Element Plus 体积

📈 性能提升分析

1. 缓存命中率提升

场景模拟:

假设每月发版 4 次,每次更新:

  • 业务代码更新:100%
  • 依赖库更新:10%

优化前:

  • 每次发版,用户需要重新下载 vendor (227 KB)
  • 月流量:227 KB × 4 = 908 KB

优化后:

  • 业务代码更新:vendor (157 KB)
  • 依赖更新(10% 概率):lodash/axios/dayjs 之一 (约 30 KB)
  • 月流量:157 KB × 4 + 30 KB × 0.4 = 640 KB

节省流量: 268 KB/月/用户(减少 29.5%

2. 首屏加载优化

并行下载优势:

优化前(串行):
[====== vendor 227KB ======] 2.27s (假设 100KB/s)

优化后(并行):
[== vendor 157KB ==] 1.57s
[= lodash 28KB =] 0.28s
[= axios 39KB ==] 0.39s
[= dayjs 18KB ==] 0.18s
总时间:max(1.57, 0.28, 0.39, 0.18) = 1.57s

加载时间减少: 0.7s(提升 30.8%

3. 用户体验提升

指标 优化前 优化后 提升
首次加载 ~3.5s ~2.8s ↓ 20%
二次访问 ~1.2s ~0.8s ↓ 33%
更新后访问 ~2.0s ~1.4s ↓ 30%

🎓 深度解析:为什么这样优化有效?

1. HTTP/2 的多路复用

现代浏览器支持 HTTP/2,可以:

  • 在单个连接上并行传输多个文件
  • 避免队头阻塞
  • 更高效的资源利用

最佳实践:

  • 单个文件大小:20-100 KB
  • 文件数量:5-15 个
  • 避免过度分割(< 10 KB 的文件)

2. 浏览器缓存策略

浏览器缓存基于文件名(包含 hash):

  • 文件内容不变 → hash 不变 → 使用缓存
  • 文件内容改变 → hash 改变 → 重新下载

精细化分包的优势:

  • 减少缓存失效的范围
  • 提高缓存命中率
  • 降低用户流量消耗

3. 关键渲染路径优化

首屏渲染需要:
1. HTML
2. 关键 CSS
3. 关键 JS(framework + main)
4. 非关键 JS(vendor + 其他库)

优化策略:
- 关键资源:内联或优先加载
- 非关键资源:延迟加载或并行加载

🛠️ 实战技巧

技巧 1:分析包体积

# 生成可视化报告
VITE_ANALYZE=true npm run build:dev

# 查看 stats.html
open dist/stats.html

关注指标:

  • 单个 chunk 大小(建议 < 200 KB)
  • 重复依赖(应该为 0)
  • 未使用的代码(通过 Tree Shaking 移除)

技巧 2:合理的分包粒度

// 🎯 最佳实践
const chunkSizeMap = {
  'element-plus': 787,  // 大型 UI 库,单独打包
  'framework': 180,     // 核心框架,单独打包
  'vendor': 157,        // 其他依赖,合并打包
  'lodash': 28,         // 常用工具库,单独打包
  'axios': 39,          // HTTP 库,单独打包
  'dayjs': 18,          // 日期库,单独打包
  'crypto': 70,         // 加密库,单独打包
}

// ❌ 过度分割
const chunkSizeMap = {
  'lodash-debounce': 2,    // 太小,不值得单独打包
  'lodash-throttle': 2,    // 太小,不值得单独打包
  'lodash-cloneDeep': 3,   // 太小,不值得单独打包
}

技巧 3:监控包体积变化

// package.json
{
  "scripts": {
    "build:analyze": "VITE_ANALYZE=true npm run build:dev",
    "size-limit": "size-limit",
    "size-limit:check": "size-limit --why"
  },
  "size-limit": [
    {
      "path": "dist/assets/js/element-plus-*.js",
      "limit": "250 KB"
    },
    {
      "path": "dist/assets/js/vendor-*.js",
      "limit": "160 KB"
    }
  ]
}

📋 优化检查清单

分包策略

  • 大型库(> 100 KB)单独打包
  • 常用库(20-100 KB)单独打包
  • 小型库(< 20 KB)合并打包
  • 避免过度分割(< 10 KB)

缓存策略

  • 使用 contenthash 命名
  • 稳定的 chunk 名称
  • 合理的缓存时间
  • CDN 配置正确

性能监控

  • 定期生成包体积报告
  • 设置体积预算
  • 监控首屏加载时间
  • 跟踪缓存命中率

🎯 下一步优化方向

1. Element Plus 深度优化

当前状态: 787 KB(Gzip: 242 KB)

优化方向:

  • 分析实际使用的组件
  • 移除未使用的组件
  • 考虑使用更轻量的替代方案

预期收益: 减少 150-200 KB

2. 动态导入优化

当前状态: 所有路由组件都在首屏加载

优化方向:

// 路由懒加载
const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/dashboard/index.vue'),
  },
  {
    path: '/settings',
    component: () => import('@/views/settings/index.vue'),
  },
]

预期收益: 首屏减少 30-40%

3. Tree Shaking 优化

当前状态: 可能存在未使用的代码

优化方向:

  • 检查 lodash-es 导入方式
  • 使用具名导入
  • 配置 sideEffects

预期收益: 减少 50-100 KB

📊 ROI 分析

投入时间: 2 小时

收益:

  • 包体积优化:69 KB(vendor)
  • 缓存效率提升:29.5%
  • 加载时间减少:30.8%
  • 用户体验提升:20-33%

长期收益:

  • 每月节省流量:268 KB × 用户数
  • 提升用户留存率
  • 降低服务器带宽成本

🎬 总结

通过精细化分包策略,我们实现了:

  1. Vendor 包瘦身:从 227 KB 减少到 157 KB
  2. 缓存效率提升:29.5% 的流量节省
  3. 加载速度提升:30.8% 的时间减少
  4. 更好的可维护性:清晰的依赖关系

核心原则

  1. 合理分包:根据更新频率和大小分包
  2. 提升缓存:减少缓存失效范围
  3. 并行加载:利用 HTTP/2 多路复用
  4. 持续监控:定期检查包体积变化

最后的建议

  • DO:定期分析包体积
  • DO:设置体积预算
  • DO:监控性能指标
  • DON'T:过度分割
  • DON'T:忽视缓存策略
  • DON'T:盲目追求极致

关键词: Vite 包体积优化、代码分割、缓存策略、性能优化、Vue 3

标签: #Vite #包体积优化 #性能优化 #前端工程化


如果这篇文章对你有帮助,别忘了点赞👍、收藏⭐️、关注➕三连!

更多前端性能优化技巧,请关注我的专栏《前端性能优化实战》

React 那么多状态管理库,到底选哪个?如果非要焊死一个呢?这篇文章解决你的选择困难症

2026年1月13日 17:55

前言

各位 React 开发者们,是不是还在为状态管理头疼?在我的这篇文章中:

🚀别再卷 Redux 了!Zustand 才是 React 状态管理的躺平神器

有掘友问到:React那么多状态库,能不能直接焊死一个?

image.png

那就简单聊下我的看法(仅供参考)。篇幅比较长,中间的代码示例大家可以跳着阅读。

📊 主流状态管理库分类

1. 客户端状态管理

  • Redux Toolkit (RTK) - 最成熟,企业级首选
  • Zustand - 轻量简洁,API 友好
  • Jotai - 原子化状态,React 风格
  • Recoil - Facebook 出品,实验性
  • Valtio - 代理基础,可变语法

2. 服务端状态管理

  • TanStack Query (React Query) - 异步数据王者
  • SWR - Vercel 出品,轻量
  • RTK Query - Redux 生态内

3. 全栈/框架集成

  • Next.js - 内置多种方案
  • Remix - 基于 loader/action
  • Nuxt (Vue)- 类比参考

🎯 我的建议:焊死这个组合

对于大多数项目,如果非要焊死一个的话,我推荐:Zustand + TanStack Query。React 太多状态管理库了,如果非要焊死一个,我目前推荐这个王炸组合。

🌈 为什么选择这个组合

一、先搞懂:为什么要分开处理两种状态?

在开始安利组合之前,我们得先明确一个核心认知:React 项目中的状态,从来都不是一锅炖的,而是分为两种截然不同的类型,需要区别对待。

  1. 客户端本地状态(UI 状态) 比如:按钮的禁用状态、侧边栏的展开 / 折叠、导航栏的当前选中项、用户的本地偏好设置等。这类状态的特点是:同步更新、无需缓存、仅在客户端生效、数据量较小
  2. 服务端异步状态(接口数据) 比如:从后端获取的用户列表、文章数据、商品信息等。这类状态的特点是:异步获取、需要处理 loading/error 状态、需要缓存、可能需要后台刷新、支持分页 / 无限加载

过去我们总想着用一套方案搞定所有状态,结果就是既要又要还要,最后搞得不伦不类。而 Zustand + TanStack Query 的组合,正是精准切中了这两种状态的需求,各司其职、强强联合。

二、 Zustand:客户端状态管理的「极简天花板」

Zustand 是一款轻量、简洁、API 友好的客户端状态管理库,它的核心理念就是少即是多—— 没有繁琐的概念,没有多余的模板代码,甚至不需要 Provider 包裹整个应用,上手即用。

1. 核心优势:为什么放弃 Redux 选择 Zustand?

  • 🚀 代码量减少 70% :无需写 Action、Reducer,无需配置 Provider,直接创建 Store 即可使用。
  • 🎉 无需 Provider 包裹:告别顶层嵌套的 Provider 地狱,尤其是在大型项目中,能极大简化组件树结构。
  • 🔒 TypeScript 支持完美:内置 TypeScript 类型推导,无需额外写大量类型声明,写起来丝滑流畅。
  • JavaScript 无缝兼容:无需额外配置类型,原生 JS 写起来丝滑流畅,新手也能快速上手。
  • 💪 足够应对 95% 的客户端状态需求:支持中间件、持久化、状态切片,扩展性拉满,小型项目和中型项目都能 hold 住。
  • 📦 超小体积:核心体积不到 1KB,对项目打包体积几乎没有影响,堪称「轻量王者」。

2. 代码示例:5 分钟上手 Zustand

第一步:安装依赖
npm install zustand 
# 或 yarn add zustand 
# 或 pnpm add zustand
第二步:创建第一个 Store

我们来写一个最简单的计数器,感受一下 Zustand 的简洁:

// src/store/count.store.js
import { create } from 'zustand';

// 创建计数器 Store
const useCountStore = create((set) => ({
    // 定义状态数据
    count: 0,
    
    // 定义修改状态的方法(无需 Action,直接修改)
    increase: () => set((state) => ({ count: state.count + 1 })),
    decrease: () => set((state) => ({ count: state.count - 1 })),
    reset: () => set({ count: 0 }),
    
    // 支持传入参数修改状态
    setCount: (num) => set({ count: num }),
}));

export default useCountStore;
// scr/App.jsx
import useCountStore from './store/count.store.js'
export default function App() {
    return (
        <div>
            <h1>Count: {useCountStore((state) => state.count)}</h1>
        </div>
    )
}

image.png

第三步:在组件中使用 Store

无需任何额外配置,直接导入使用,就是这么简单:

// src/components/CountComponent.jsx
import useCountStore from '../store/count.store';

const CountComponent = () => {
    // 按需获取状态和方法(支持解构,不会触发不必要的重渲染)
    const count = useCountStore((state) => state.count);
    const { increase, decrease, reset } = useCountStore();

    return (
        <div style={{ padding: '20px', border: '1px solid #eee', borderRadius: '8px' }}>
            <h3 style={{ color: '#1890ff' }}>Zustand 计数器示例</h3>
            <p style={{ fontSize: '24px', margin: '20px 0' }}>当前计数:{count}</p>
            <div>
                <button
                    onClick={increase}
                    style={{ marginRight: '10px', padding: '8px 16px', cursor: 'pointer' }}
                >
                    +1
                </button>
                <button
                    onClick={decrease}
                    style={{ marginRight: '10px', padding: '8px 16px', cursor: 'pointer' }}
                >
                    -1
                </button>
                <button
                    onClick={reset}
                    style={{ padding: '8px 16px', cursor: 'pointer', backgroundColor: '#f5f5f5' }}
                >
                    重置
                </button>
            </div>
        </div>
    );
};

export default CountComponent;

image.png

进阶示例: Zustand 持久化(本地存储用户偏好)

如果需要将状态持久化到 localStorage(比如用户的侧边栏偏好),Zustand 也能轻松实现,只需借助内置的中间件:

// src/store/ui.store.js
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

// 创建 UI 状态 Store,并开启持久化
const useUiStore = create(
    persist(
        (set) => ({
            // 侧边栏展开状态
            sidebarCollapsed: false,
            // 切换侧边栏状态
            toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
        }),
        {
            // 持久化的 key(用于 localStorage 中存储的键名)
            name: 'ui-preferences',
        }
    )
);

export default useUiStore;

使用起来和普通 Store 毫无区别,但是状态会自动同步到 localStorage,页面刷新后也不会丢失,个人觉得还是挺方便的。

三、 TanStack Query:服务端状态管理的「异步王者」

如果说 Zustand 是客户端状态的极简天花板,那么 TanStack Query(原 React Query)就是服务端状态的异步王者

它的核心作用是:帮你封装了所有服务端数据处理的繁琐逻辑,让你像使用本地状态一样使用异步接口数据。你再也不用手动处理 loading、error、缓存、重试这些问题,只需专注于编写接口请求函数即可。

1. 核心优势:为什么选择 TanStack Query?

  • 🚀 自动缓存:请求的数据会自动缓存,相同的请求不会重复发送,极大减少接口请求次数。
  • 🎉 自动处理 loading/error 状态:内置 loading、error、data 状态,无需手动声明和更新。
  • 💪 后台数据同步:支持后台刷新数据,页面在前台时自动更新最新数据,无需用户手动刷新。
  • 📦 内置分页 / 无限加载 / 乐观更新:提供丰富的 Hooks 支持复杂的异步数据场景,无需自己造轮子。
  • 🔄 自动重试:请求失败时可以配置自动重试,提高接口的容错性。
  • 🧰 强大的 DevTools:配套的开发者工具,能清晰看到请求的缓存、状态、历史记录,调试更方便。

2. 代码示例:5 分钟上手 TanStack Query

第一步:安装依赖
npm install @tanstack/react-query @tanstack/react-query-devtools
# 或 yarn add @tanstack/react-query @tanstack/react-query-devtools
# 或 pnpm add @tanstack/react-query @tanstack/react-query-devtools
第二步:全局配置 TanStack Query

首先需要在项目入口文件中配置 QueryClientQueryClientProvider,这是唯一需要全局配置的步骤:

// src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

// 创建 QueryClient 实例
const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            // 默认开启缓存,5 分钟内不重复请求
            staleTime: 5 * 60 * 1000,
            // 请求失败时自动重试 3 次
            retry: 3,
            // 关闭无限加载(可选)
            refetchOnWindowFocus: false,
        },
    },
});

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
    <QueryClientProvider client={queryClient}>
        <App />
        {/* 挂载 DevTools(开发环境开启,生产环境可移除) */}
        <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
);
第三步:封装接口请求 Hook

我们来封装一个获取待办事项的 Hook,感受一下 TanStack Query 的强大:

// src/api/todos.api.js
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

// 1. 定义接口请求函数
const fetchTodos = async () => {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos');
    if (!response.ok) {
        throw new Error('获取待办事项失败');
    }
    return response.json();
};

// 2. 定义新增待办事项的函数(纯 JavaScript)
const addTodo = async (newTodo) => {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify(newTodo),
    });
    if (!response.ok) {
        throw new Error('新增待办事项失败');
    }
    return response.json();
};

// 3. 封装获取待办事项的 Hook(使用 useQuery,纯 JavaScript)
export const useTodosQuery = () => {
    return useQuery({
        // queryKey:缓存的唯一标识,必须是数组类型(支持依赖项传递)
        queryKey: ['todos'],
        // queryFn:接口请求函数
        queryFn: fetchTodos,
    });
};

// 4. 封装新增待办事项的 Hook(使用 useMutation,处理 POST/PUT/DELETE 请求)
export const useAddTodoMutation = () => {
    const queryClient = useQueryClient();

    return useMutation({
        mutationFn: addTodo,
        // 新增成功后,自动刷新待办事项列表(乐观更新)
        onSuccess: () => {
            queryClient.invalidateQueries({ queryKey: ['todos'] });
        },
    });
};
第四步:在组件中使用接口 Hook

无需手动处理 loading 和 error,直接解构使用即可,代码简洁到飞起:

// src/components/TodoComponent.jsx
import React, { useState } from 'react';
import { useTodosQuery, useAddTodoMutation } from '../api/todos.api';

const TodoComponent = () => {
    const [title, setTitle] = useState('');
    // 获取待办事项数据
    const { data: todos, isLoading, isError, error } = useTodosQuery();
    // 新增待办事项
    const { mutate: addTodo, isPending: isAdding } = useAddTodoMutation();

    // 处理新增待办事项提交
    const handleSubmit = (e) => {
        e.preventDefault();
        if (!title.trim()) return;
        addTodo({ title, completed: false });
        setTitle('');
    };

    // 加载中状态
    if (isLoading) {
        return <div style={{ padding: '20px' }}>正在获取待办事项...</div>;
    }

    // 错误状态
    if (isError) {
        return <div style={{ padding: '20px', color: '#ff4d4f' }}>获取失败:{error.message}</div>;
    }

    return (
        <div style={{ padding: '20px', border: '1px solid #eee', borderRadius: '8px', marginTop: '20px' }}>
            <h3 style={{ color: '#1890ff' }}>TanStack Query 待办事项示例(JSX)</h3>

            {/* 新增待办事项表单 */}
            <form onSubmit={handleSubmit} style={{ marginBottom: '20px' }}>
                <input
                    type="text"
                    value={title}
                    onChange={(e) => setTitle(e.target.value)}
                    placeholder="请输入待办事项"
                    style={{ padding: '8px', width: '300px', marginRight: '10px' }}
                />
                <button
                    type="submit"
                    disabled={isAdding}
                    style={{ padding: '8px 16px', cursor: 'pointer', backgroundColor: '#1890ff', color: '#fff', border: 'none', borderRadius: '4px' }}
                >
                    {isAdding ? '新增中...' : '新增待办'}
                </button>
            </form>

            {/* 待办事项列表 */}
            <div>
                <h4>待办列表(前 10 条)</h4>
                <ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
                    {todos?.slice(0, 10).map((todo) => (
                        <li
                            key={todo.id}
                            style={{
                                padding: '10px',
                                borderBottom: '1px solid #f5f5f5',
                                textDecoration: todo.completed ? 'line-through' : 'none',
                                color: todo.completed ? '#999' : '#333',
                            }}
                        >
                            {todo.title}
                        </li>
                    ))}
                </ul>
            </div>
        </div>
    );
};

export default TodoComponent;

运行代码后,你会发现:请求自动发送、加载状态自动处理、新增数据后列表自动刷新、相同请求不会重复发送 —— 这一切,都是 TanStack Query 帮你做好的,你只需要专注于业务逻辑即可。

四、 王炸组合落地:项目结构最佳实践

看完了两个库的单独使用,我们再来看看如何在实际项目中整合 Zustand + TanStack Query,打造一个清晰、可维护的项目结构。

src/
├── store/           # Zustand 客户端状态存储目录
│   ├── auth.store.js  # 认证相关状态(登录状态、用户信息)
│   ├── ui.store.js    # UI 相关状态(侧边栏、主题、导航)
│   └── index.js       # Store 导出汇总(方便组件导入)
├── api/            # TanStack Query 接口 Hook 目录
│   ├── todos.js       # 待办事项相关接口
│   ├── users.js       # 用户相关接口
│   └── index.js       # 接口 Hook 导出汇总
├── components/     # 公共组件目录
├── pages/          # 页面组件目录
└── App.jsx         # 根组件

🚀 快速决策指南

可能有人会问:我的项目很小,需要用这套组合吗?我的项目是大型企业级项目,这套组合够用吗?

别急,我让DeepSeek给大家整理了一份懒人快速决策指南,对应不同场景选择最合适的方案:

  1. 超简单状态(单个组件内、无需共享):直接用 useState 即可,无需引入任何状态库,简单直接。
  2. 小型项目 / 简单共享状态(少量组件共享状态):可以用 React Context + useReducer,或者直接用 Zustand(上手更快,代码更简洁)。
  3. 中型项目(推荐,90% 的项目场景):直接焊死 Zustand + TanStack Query,开发体验最佳,覆盖 99% 的场景,后期维护成本低。
  4. 大型企业级项目(需要强架构、可追溯、团队协作):可以选择 Redux Toolkit + RTK Query,支持时间旅行调试、丰富的中间件生态,适合对架构有严格要求的大型项目。
  5. 超极简需求(只需要原子化状态):可以选择 Jotainanostores,原子化状态管理,按需更新,体积更小。

📝 具体落地建议

// 1. 安装核心依赖
"dependencies": {
  "zustand": "^4.0.0",
  "@tanstack/react-query": "^5.0.0",
  "@tanstack/react-query-devtools": "^5.0.0"
}

// 2. 项目结构
src/
├── store/           # Zustand stores
│   ├── auth.store.ts
│   ├── ui.store.ts
│   └── index.ts
├── api/            # TanStack Query hooks
│   ├── todos.ts
│   └── users.ts
└── components/

🔄 迁移策略

如果你现在用 Redux,逐步迁移:

  1. 新功能用 Zustand
  2. 旧功能保持 Redux
  3. 两者可以共存

💡 黄金法则

  1. 先判断状态类型

    • 服务器数据?→ TanStack Query
    • 客户端 UI 状态?→ Zustand
    • 表单状态?→ React Hook Form + Zustand
  2. 避免过度设计

    • 能用 useState 就别用状态库
    • 组件内状态优先
    • 共享状态才提升
  3. 技术选型标准

    • 团队熟悉度
    • 维护活跃度
    • TypeScript 支持
    • Bundle 大小

🎖️ 最终答案

非要焊死的话,那我推荐这个组合:Zustand + TanStack Query

这个组合能覆盖:

  • ✅ 客户端状态(Zustand)
  • ✅ 服务端状态(TanStack Query)
  • ✅ 表单状态(React Hook Form)
  • ✅ URL 状态(React Router)

对于 90% 的 React 项目,这套组合是最佳实践。除非你有特殊需求(如需要 Redux 中间件生态或时间旅行调试)。

结语

到这里,相信大家已经对 Zustand + TanStack Query 这套王炸组合有了全面的了解。

这套组合的核心魅力就在于:简洁、高效、各司其职。Zustand 搞定客户端本地状态,让你告别繁琐的 Provider 和模板代码;TanStack Query 搞定服务端异步数据,让你告别手动处理 loading/error/ 缓存的烦恼。

祝大家编码愉快,少写 bug,多摸鱼~ 🚀

团队协作新范式:用Cursor构建智能前端工作流

作者 Mr_chiu
2026年1月13日 17:54

当AI编程助手从个人工具升级为团队基础设施,前端开发的协作模式正在发生根本性变革

前言:从“个人加速器”到“团队增强器”

在前两篇文章中,我们已经探索了Cursor如何改变个人开发体验和重构工作流。然而,真正的生产力革命发生在团队层面——当每个人都使用AI助手时,如何确保协作的一致性、代码质量的统一性和知识的有效传递?

一家中型电商团队的经历颇具代表性:最初只是几位工程师尝试使用Cursor,很快发现各自生成的代码风格迥异,缺乏统一的模式和规范。两个月后,他们建立了一套共享的Cursor配置和团队规范,代码审查时间减少了40%,新成员上手速度提升了60%。

本篇将深入探讨如何将Cursor从个人生产力工具,转变为团队协作的基础设施,打造真正智能化的前端工作流。

一、建立团队统一的Cursor配置系统

1.1 团队级.cursorrules配置规范

与个人使用不同,团队协作需要一套共享的“AI编程规范”。这不仅仅是编码风格,更是团队技术决策的体现。

团队配置文件示例

# .cursorrules/team-guidelines.md
# ===============================
# 团队AI协作规范 v2.1
# 适用于所有使用Cursor的团队成员

## 架构决策记录(ADR)
- 状态管理:统一使用Zustand,禁止新增Redux代码
- 样式方案:Tailwind CSS + CSS Modules组合方案
- 组件库:内部组件库前缀统一为 `App` (如AppButton)
- API客户端:统一使用基于axios封装的httpClient

## 代码生成约束
### 禁止生成的模式
- 避免生成内联样式对象,除非是动态计算值
- 禁止使用`any`类型,必须显式定义接口
- 避免生成超过100行的单个组件文件

### 推荐模式
- 优先生成函数组件而非类组件
- 使用TypeScript严格模式
- 遵循React Hooks最佳实践

## 项目特定规则
### 电商模块
- 价格计算统一使用`formatPrice`工具函数
- 商品SKU验证逻辑必须通过`validateSKU`函数
- 购物车状态必须与用户会话绑定

### 用户系统
- 权限检查使用`usePermissions`自定义Hook
- 用户数据流必须经过清理和验证

配置同步策略

# 将团队配置纳入版本控制
git add .cursorrules/
git commit -m "chore: 更新团队Cursor规范v2.1"

# 使用Husky钩子确保配置同步
# 在.husky/pre-commit中添加
if [ -f ".cursorrules/team-guidelines.md" ]; then
  echo "检查Cursor配置版本..."
  # 验证本地配置与远程一致
fi

1.2 智能代码片段库:团队的“集体智慧”

Cursor的强大之处在于能够学习团队的代码模式。建立一个共享的智能代码片段库,可以确保最佳实践在团队中传播。

创建团队片段库的方法

// .cursor/snippets/README.md
// 团队共享代码片段库

// 1. 常用业务模式
// =================
// 电商价格展示组件模式
/**
 * @snippet price-display
 * @desc 统一的价格展示组件,支持折扣、原价显示
 * @tags 电商,价格,组件
 */
const PriceDisplay: React.FC<PriceDisplayProps> = ({ 
  price, 
  originalPrice,
  currency = 'CNY'
}) => {
  // 团队统一的格式化逻辑
  const formattedPrice = formatPrice(price, currency);
  const hasDiscount = originalPrice && originalPrice > price;
  
  return (
    <div className="price-container">
      <span className="current-price">{formattedPrice}</span>
      {hasDiscount && (
        <span className="original-price">
          {formatPrice(originalPrice, currency)}
        </span>
      )}
    </div>
  );
};

// 2. API请求模式
/**
 * @snippet api-hook-pattern
 * @desc 标准的API请求Hook模式
 * @tags api,hook,请求
 */
export const useApiResource = <T,>(endpoint: string, initialData: T) => {
  const [data, setData] = useState<T>(initialData);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  
  const fetchData = useCallback(async (params?: Record<string, any>) => {
    setLoading(true);
    try {
      const response = await httpClient.get(endpoint, { params });
      setData(response.data);
      setError(null);
    } catch (err) {
      setError('请求失败');
      console.error(`API错误 [${endpoint}]:`, err);
    } finally {
      setLoading(false);
    }
  }, [endpoint]);
  
  return { data, loading, error, fetchData, setData };
};

二、智能化代码审查与质量保证

2.1 Cursor辅助的代码审查工作流

传统的代码审查往往关注语法细节,而有了Cursor,审查可以更专注于架构和业务逻辑。

智能审查指令集

# 代码提交前的自动审查
# 在package.json中配置
"scripts": {
  "cursor-review": "cursor --review-changes --rules .cursorrules/team-guidelines.md",
  "precommit-review": "cursor --staged --output review-report.md"
}

# 常用的审查指令
指令:"审查这段代码,重点关注:
1. 是否符合团队的状态管理规范
2. 是否有潜在的性能问题
3. 错误处理是否完整
4. 可访问性是否达标"

# 生成审查报告
指令:"生成详细的代码审查报告,包括:
- 架构符合度评分
- 潜在风险列表
- 具体改进建议
- 重构优先级"

集成到现有工作流

# .github/workflows/cursor-review.yml
name: AI-Assisted Code Review
on: [pull_request]

jobs:
  cursor-review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: 设置Cursor环境
        uses: actions/setup-node@v3
        
      - name: 运行AI辅助审查
        run: |
          npx cursor-review@latest \
            --github-token ${{ secrets.GITHUB_TOKEN }} \
            --pr-number ${{ github.event.pull_request.number }} \
            --rules .cursorrules/team-guidelines.md
          
      - name: 发布审查报告
        uses: actions/github-script@v6
        with:
          script: |
            // 将审查结果发布到PR评论

2.2 自定义审查规则的进阶应用

Cursor允许团队定义自己的审查规则,这些规则可以捕捉团队特定的问题模式。

// .cursor/custom-rules/performance-rules.js
// 自定义性能审查规则

module.exports = {
  rules: {
    '避免大组件': {
      pattern: /const\s+\w+\s*=\s*\([^)]*\)\s*=>\s*{[^}]{200,}}/gs,
      message: '组件超过200行,建议拆分为更小的子组件',
      severity: 'warning'
    },
    
    'useEffect依赖项检查': {
      pattern: /useEffect\s*\(\s*\(\)\s*=>\s*\{[^}]+?\}\s*,\s*\[\s*\]\s*\)/g,
      message: 'useEffect缺少依赖项,可能导致过时闭包问题',
      severity: 'error'
    },
    
    '图片优化提醒': {
      pattern: /<img[^>]*src=["']([^"']+)["'][^>]*>/g,
      check: async (match, filePath) => {
        const src = match[1];
        // 检查是否为WebP格式,是否设置合适尺寸
        if (!src.includes('.webp') && !src.startsWith('data:')) {
          return '建议使用WebP格式图片以提高性能';
        }
        return null;
      }
    }
  }
};

三、团队知识管理与智能文档

3.1 项目文档的自动生成与维护

文档与代码脱节是团队常见问题。Cursor可以基于代码和注释,自动生成和更新文档。

自动化文档工作流

// 1. 智能组件文档生成
/**
 * @component UserProfile
 * @description 用户个人资料展示组件
 * @prop {User} user - 用户数据对象
 * @prop {boolean} editable - 是否可编辑模式
 * @prop {Function} onUpdate - 资料更新回调
 * @example
 * <UserProfile 
 *   user={currentUser}
 *   editable={true}
 *   onUpdate={handleUserUpdate}
 * />
 */
const UserProfile: React.FC<UserProfileProps> = ({ user, editable, onUpdate }) => {
  // 组件实现...
};

// 让Cursor基于组件生成Markdown文档
指令:"基于以上组件代码和注释,生成完整的组件文档,包括:
- Props详细说明表格
- 使用示例(包括TypeScript示例)
- 常见问题解答
- 相关组件链接"

// 2. API文档自动同步
指令:"扫描所有API service文件,生成统一的API文档:
- 每个端点的URL和方法
- 请求参数类型和示例
- 响应数据格式
- 错误码说明"

3.2 团队知识库的AI增强

建立一个“活”的知识库,可以回答团队的技术问题。

# 团队知识库:AI增强版

## 架构决策查询
问:“我们为什么选择Zustand而不是Redux Toolkit?”
AI回答:“根据2023年10月的架构决策记录(ADR-003),选择Zustand的主要原因是:
1. 更简单的API,学习成本降低60%
2. 包体积减少42%
3. 与React 18并发特性兼容更好
4. 具体性能对比数据见 `/docs/decisions/state-management-choice.md`## 代码模式查询
问:“如何处理表单提交的loading状态和错误?”
AI回答:“团队标准模式是使用 `useSubmitHandler` Hook:
```typescript
// 示例代码
const { submit, isLoading, error } = useSubmitHandler(
  formData => api.post('/endpoint', formData),
  { successMessage: '提交成功' }
);

完整示例见 /examples/form-handling/advanced-form.tsx

故障排除指南

问:“页面首次加载白屏3秒,如何排查?” AI回答:“按照团队性能排查清单:

  1. 检查Bundle大小:npm run analyze
  2. 检查代码分割:确保路由使用React.lazy
  3. 检查API响应时间:使用Performance面板
  4. 查看已知问题:/docs/troubleshooting/white-screen.md 请运行诊断脚本:npm run diagnose:performance

## 四、智能化的团队协作工作流

### 4.1 统一的任务分解与估算

Cursor可以帮助团队更准确地进行任务分解和工作量估算。

**智能任务拆解模板**:
```markdown
# 功能开发任务卡

## 任务描述
{{任务描述}}

## AI辅助拆解

指令:“将以下功能需求拆解为具体的开发任务: {{粘贴需求文档}}

要求:

  1. 按前端组件粒度拆解
  2. 估算每个任务的理想人时(考虑团队平均速度)
  3. 识别技术风险和依赖项
  4. 推荐开发顺序

## 拆解结果
### 阶段1:基础架构(预计:8人时)
- [ ] 创建数据模型和TypeScript接口(2人时)
- [ ] 设置API service层(3人时)
- [ ] 配置状态管理store(3人时)

### 阶段2:核心组件(预计:12人时)
- [ ] 主列表组件(4人时)
- [ ] 详情弹窗组件(4人时)
- [ ] 搜索筛选组件(4人时)

### 阶段3:集成与优化(预计:6人时)
- [ ] 路由集成(2人时)
- [ ] 性能优化(2人时)
- [ ] 错误处理(2人时)

## 技术风险
1. API响应格式可能与预期不符
2. 大数据量下的列表性能需要关注

4.2 新人入职的AI加速

为新成员配置专门的Cursor规则,可以大幅缩短上手时间。

# .cursorrules/onboarding-guide.md
# 新人专属配置

## 学习路径引导
欢迎使用团队AI助手!以下是你第一个月应该关注的内容:

### 第一周:了解基础
- 运行 `npm run explore:architecture` 查看项目结构
- 使用指令:“解释项目的状态管理架构”
- 完成交互式教程:`npm run tutorial:core-concepts`

### 第二周:动手实践
- 使用代码生成模板创建你的第一个组件
- 指令:“创建一个商品卡片组件,参考 `components/product/ProductCard.tsx` 的模式”
- 运行自动代码审查了解团队标准

### 第三周:深度参与
- 尝试重构一个小模块
- 使用指令:“优化这个组件,使其更容易测试”
- 查看团队的代码审查记录学习最佳实践

### 第四周:独立贡献
- 认领一个简单的功能需求
- 使用任务拆解功能规划工作
- 提交你的第一个Pull Request

## 新人常见问题快速通道
问:“如何开始开发新功能?”
答:运行 `npm run create:feature feature-name` 使用标准模板

问:“遇到问题应该问谁?”
答:1. 首先问AI助手 2. 查看知识库 3. 在团队频道提问

问:“如何确保我的代码符合规范?”
答:每次提交前运行 `npm run precommit-check`

五、挑战与解决方案:团队引入Cursor的实战经验

5.1 常见挑战与应对策略

挑战 现象 解决方案
代码风格碎片化 不同人生成的代码风格迥异 建立团队统一的.cursorrules配置,定期同步更新
过度依赖 成员不加思考地接受AI建议 设置“AI生成代码必须标注出处”规则,定期进行代码审查会
知识孤岛 AI学习个人习惯而非团队模式 建立共享的代码片段库和审查规则库
审查负担加重 AI生成大量代码增加审查难度 实现自动化的预审查,只将关键问题提交人工审查

5.2 实施路线图建议

graph LR
    A[第一阶段:试点] --> B[第二阶段:标准化]
    B --> C[第三阶段:集成化]
    C --> D[第四阶段:智能化]
    
    subgraph A
        A1[2-3名早期使用者]
        A2[个人规则摸索]
        A3[收集使用场景]
    end
    
    subgraph B
        B1[团队基础规范]
        B2[共享配置库]
        B3[基础代码审查规则]
    end
    
    subgraph C
        C1[CI/CD集成]
        C2[自动化文档]
        C3[知识库增强]
    end
    
    subgraph D
        D1[预测性建议]
        D2[智能任务分配]
        D3[自适应学习系统]
    end

5.3 关键成功指标

团队引入Cursor后,应该跟踪以下指标:

interface TeamAIMetrics {
  // 开发效率
  featureLeadTime: number; // 功能从开始到交付的时间
  codeReviewCycleTime: number; // 代码审查周期
  
  // 代码质量
  bugRate: number; // 每千行代码的bug数
  technicalDebtIndex: number; // 技术债务指数
  
  // 团队协作
  onboardingTime: number; // 新成员上手时间
  knowledgeSharingScore: number; // 知识共享评分
  
  // AI使用效果
  aiAdoptionRate: number; // AI建议采纳率
  aiGeneratedCodeQuality: number; // AI生成代码质量评分
}

// 月度检查点示例
const checkCursorAdoption = (metrics: TeamAIMetrics) => {
  console.log(`AI采用报告:
  1. 开发效率提升: ${((1 - metrics.featureLeadTime / baseline) * 100).toFixed(1)}%
  2. 代码审查时间减少: ${((1 - metrics.codeReviewCycleTime / baseline) * 100).toFixed(1)}%
  3. 新人上手速度提升: ${((baseline / metrics.onboardingTime - 1) * 100).toFixed(1)}%
  4. AI代码质量评分: ${metrics.aiGeneratedCodeQuality}/10`);
};

六、未来展望:AI增强团队的进化路径

6.1 下一阶段:预测性协作

未来的团队AI助手将不仅响应指令,还能主动提出建议:

// 预测性建议示例
interface PredictiveSuggestion {
  type: 'refactor' | 'optimization' | 'documentation' | 'testing';
  priority: 'high' | 'medium' | 'low';
  description: string;
  estimatedImpact: {
    timeSaved: string; // 预计节省时间
    qualityImprovement: string; // 质量提升
    riskReduction: string; // 风险降低
  };
  action: {
    command: string; // 执行的命令
    autoApply: boolean; // 是否自动应用
  };
}

// AI可能主动建议:
{
  type: 'optimization',
  priority: 'high',
  description: '检测到商品列表组件在移动端有性能问题,建议虚拟滚动',
  estimatedImpact: {
    timeSaved: '首次加载减少1.2秒',
    qualityImprovement: '移动端FCP提升40%',
    riskReduction: '低内存设备崩溃率降低'
  },
  action: {
    command: 'cursor --optimize ProductList --strategy virtual-scroll',
    autoApply: false
  }
}

6.2 团队AI文化培育

最终,Cursor不仅是一个工具,更是团队文化的一部分:

  1. 透明化AI决策:记录重要的AI建议和采纳原因
  2. 集体学习机制:定期分享AI使用技巧和发现的最佳实践
  3. 伦理与责任框架:明确AI生成代码的责任归属和质量标准
  4. 持续进化心态:随着AI能力提升,不断调整团队工作方式

结语:重新定义“团队智慧”

Cursor等AI编程助手的出现,正在重新定义“团队智慧”的含义。传统意义上的团队智慧依赖于资深成员的指导和知识传递,而现在,这种智慧可以被编码、共享和增强。

真正的团队AI协作不是让人像机器一样工作,而是让机器像最佳团队成员一样辅助人工作。

当每个团队成员都拥有一个理解项目上下文、掌握团队规范、记得所有历史决策的AI搭档时,团队的集体智慧将被放大到前所未有的程度。


下篇预告:在第四篇中,我们将探索Cursor与现代前端技术栈的深度结合,包括Next.js 14应用架构、React Server Components、边缘计算等前沿领域的实战应用,展示AI如何帮助团队保持在技术浪潮的前沿。

团队实践挑战:在你们团队中选择一个小的协作痛点(如代码审查、知识传递、新人培训),尝试用本文的方法设计一个AI增强的解决方案,并在评论区分享你的设计和实施结果!

vue3,TypeScript---eslint配置

作者 SsunmdayKT
2026年1月13日 17:27

你想要知道在 Vue3 + TypeScript 项目中配置 ESLint 需要安装哪些 npm 包,以及对应的安装和配置方法,我会详细为你说明。

一、核心依赖包(分基础和 Vue/TS 适配)

在 Vue3 + TS 项目中,ESLint 核心依赖分为基础包适配 Vue/TS 的插件包,以下是完整的依赖列表及作用:

包名 作用
eslint ESLint 核心库,提供代码检查的基础能力
@typescript-eslint/eslint-plugin 为 TypeScript 提供 ESLint 规则的插件
@typescript-eslint/parser ESLint 的 TypeScript 解析器,让 ESLint 能识别 TS 语法
eslint-plugin-vue 专为 Vue 设计的 ESLint 插件,提供 Vue 代码的检查规则(支持 Vue3)
vue-eslint-parser 解析 Vue SFC(单文件组件)的解析器,让 ESLint 能识别 Vue 模板和脚本
@vue/eslint-config-typescript Vue 官方提供的 TS 适配配置,简化 Vue+TS 的 ESLint 配置
eslint-config-prettier(可选) 禁用 ESLint 中与 Prettier 冲突的规则(如果同时用 Prettier)

二、安装命令

1. 基础安装(仅 ESLint + Vue3 + TS)

bash

运行

# npm 安装
npm install eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-vue vue-eslint-parser @vue/eslint-config-typescript --save-dev

# yarn 安装
yarn add eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-vue vue-eslint-parser @vue/eslint-config-typescript -D

# pnpm 安装(推荐,速度更快)
pnpm add eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-vue vue-eslint-parser @vue/eslint-config-typescript -D

三、核心配置(.eslintrc.js)

安装完成后,需要在项目根目录创建 .eslintrc.js 文件,配置适配 Vue3 + TS 的规则:

javascript

运行

module.exports = {
  // 环境:指定代码运行的环境,启用对应环境的全局变量
  env: {
    browser: true, // 浏览器环境
    es2021: true,  // ES2021 语法
    node: true     // Node.js 环境
  },
  // 扩展配置:继承已有的规则集,减少重复配置
  extends: [
    'eslint:recommended', // ESLint 官方推荐规则
    'plugin:vue/vue3-essential', // Vue3 核心规则(必选)
    'plugin:@typescript-eslint/recommended', // TS 推荐规则
    '@vue/eslint-config-typescript', // Vue+TS 适配配置
    'eslint-config-prettier' // 禁用与 Prettier 冲突的规则(如果装了 Prettier)
  ],
  // 解析器:指定解析代码的解析器
  parser: 'vue-eslint-parser', // 解析 Vue SFC
  // 解析器选项:传给解析器的配置
  parserOptions: {
    ecmaVersion: 'latest', // 支持最新 ES 版本
    parser: '@typescript-eslint/parser', // 解析 TS 代码的子解析器
    sourceType: 'module' // 模块化代码(ES Module)
  },
  // 插件:启用安装的 ESLint 插件
  plugins: [
    'vue', // Vue 插件
    '@typescript-eslint' // TS 插件
  ],
  // 自定义规则:覆盖或新增规则,优先级最高
  rules: {
    // 示例:关闭 TS 的 "any 类型禁止" 规则(根据项目需求调整)
    '@typescript-eslint/no-explicit-any': 'off',
    // 关闭 Vue 的 "组件名多单词" 规则(小型项目可临时关闭)
    'vue/multi-word-component-names': 'off'
  }
}

四、补充配置(可选)

  1. 忽略文件(.eslintignore) :指定 ESLint 不检查的文件 / 目录

plaintext

node_modules/
dist/
*.d.ts

五、自动化修复

1.安装插件

image.png

2.添加自动配置项

image.png

image.png

添加

image.png

  "editor.codeActionsOnSave":{
    "source.fixAll.eslint": "explicit"
  },

服务端返回的二进制流excel文件,前端实现下载

2026年1月13日 17:06

近期有个excel的下载功能,服务端返回的是二进制的文件流,前端实现excel文件下载。

简易axios:

// utils/request.ts
import axios, { AxiosRequestConfig, AxiosRequestHeaders } from 'axios'
axios.interceptors.request.use(config => {
    ……
    return config
})
axios.interceptors.response.use(
    response => {
        return new Promise((resolve, reject) => {
            ……
        })
    }
)
    
 export const getBlobFile = (url: string, params = {}) =>
    axios({
        data: params,
        responseType: 'blob',
        method: 'POST',
        url
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
    }) as Promise<any>

下面是工具函数文件的方法:

// utils/index.ts
export function useState<T>(initData: T): [Ref<T>, (val?: T) => void] {
    const data = ref(initData) as Ref<T>
    function setData(newVal?: T) {
        data.value = newVal || initData
    }
    return [data, setData]
}

/**
 * 下载二进制文件
 * @param file
 * @param fn
 */
import { ref, Ref } from 'vue'

export function downloadFile(file: File, fn?: () => void) {
    if ('msSaveOrOpenBlob' in navigator) {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const nav = navigator as any
        nav.msSaveOrOpenBlob(file, file.name)
    } else {
        const url = URL.createObjectURL(file)
        const event = new MouseEvent('click')
        const link = document.createElement('a')
        link.href = url
        link.download = file.name
        file.type && (link.type = file.type)
        link.dispatchEvent(event)
        URL.revokeObjectURL(url)
        fn && fn()
    }
}

实现下载相关逻辑的hooks如下:

// hooks.ts
import { ref } from 'vue'
import { ElLoading } from 'element-plus'
import { EXCEL_EXPORT_URL } from '@/const/url'
import { useState, downloadFile } from '@/utils'
import { getBlobFile } from '@/utils/request'

export const OK_STATUS_CODE = '200'

export function useExportExcelFile() {
    const [exportData, handleExport] = useState([] as Array<ApiRes.ExcelListItem>)

    const exportLoading = ref(false)

    async function exportExcelFile(params = {}) {
        const text = '正在导出当前数据,请稍等~~ '
        const loading = ElLoading.service({
            lock: true,
            text,
            background: 'rgba(0, 0, 0, 0.7)',
            customClass: 'export-loading-class'
        })
        try {
            queryBlobExcelFile(params).then((res) => {
                exportExcelFileByPost(res)
            }).finally(() => {
                loading.close()
                exportLoading.value = false
            })
        } catch (error) {
            console.error('导出失败:', error)
        }
    }

    async function queryBlobExcelFile (params = {}) {
        return new Promise((resolve, reject) => {
            getBlobFile(EXCEL_EXPORT_URL, params)
                .then(res => {
                    if (res && res?.status === OK_STATUS_CODE) {
                        resolve(res)
                    }
                })
                .catch(err => reject(err))
        })
    }

    async function exportExcelFileByPost(res: {
        type: string
        data: Blob
    }) {
        const fileName =  `Excel文件-${+new Date()}.xlsx`
        downloadFile(new File([res.data], fileName))
    }

    return {
        exportData,
        handleExport,
        exportLoading,
        exportExcelFile,
    }
}

在页面中的使用

</template>
    <el-button
        type="primary"
        :disabled="false"
        :export-loading="exportLoading"
        @click="doExport"
    >
        导出
    </el-button>
</template>

import { ElMessageBox } from 'element-plus'
import { useExportExcelFile } from './hooks'

// 导出
const { exportLoading, exportExcelFile } = useExportExcelFile()
function doExport() {
    ElMessageBox.confirm('确定导出excel数据吗?', '导出', {
        cancelButtonText: '取消',
        confirmButtonText: '确认',
        showClose: true
    }).then(() => {
        exportLoading.value = true
        exportExcelFile(formData)
    })
}

TypeScript 类型推导还可以这么用?

2026年1月13日 17:03

一、为什么需要TypeScript类型推导

肯定是为了节省我们的代码,减少冗余,下面举一个例子,写一个递减函数

下面代码中,写了三个类型都是number,那么我们是不是可以思考,如何减少冗余呢,ts给咱们提供了泛型

正常书写

const desc = (a: number): number => {
    return --a
}
desc(2) // 1

泛型

const desc = <T>(a: T): number => {
    return --a
}
desc(3) // 2

这个例子在项目中的应用场景可谓之少之又少,也可以说,压根不可能出现。
那么衍生出一个问题:有没有更好的方法,自动推导我特定的入参返回特定类型。

有的兄弟,有的!!

我先把公共代码抽出来

// 公共属性
type Pet = {
    name: string
    age: number
}

// 映射类型
type OptionsMap = {
    dog?: {
        bark: string
        barkVolume: number
    } & Pet

    cat?: {
        favoriteToy: string
        furLength: 'short' | 'medium' | 'long'
    } & Pet
}

场景:

  • 组件封装,表单、表单组件类型封装,例如通过表单项type来确定是input、select...就可以明确props类型了
  • 业务逻辑封装,在重复的业务逻辑中我们通常会抽离,通过key去调用,那么对应的参数不同,也会用到泛型、自动推导

二、映射 + 类型推导

我们可以通过映射、类型推导相互配合就能得到特定的类型了,废话少说,上代码

2.1. 先来一个函数案例,通过参数1推导出参数2


const fn = <T extends keyof OptionsMap>(type: T, props: OptionsMap[T]) => {}

fn('cat', {}) 
/*
类型“{}”的参数不能赋给类型“{ favoriteToy: string; furLength: "short" | "medium" | "long"; } & Pet”的参数。  
类型“{}”缺少类型“{ favoriteToy: string; furLength: "short" | "medium" | "long"; }”中的以下属性: favoriteToy, furLengthts-plugin(2345)
*/ 

  • 上面代码声明了宠物映射属性,通过函数的第一个参数去映射中自动匹配第二个参数
  • 恭喜你现在已经掌握类型推导的基础了

2.2. 下面我们再来一个写一个生成集合,里面数据项都是通过推导出来的

type SinglePet<T extends keyof OptionsMap> = {
    type: keyof OptionsMap
    options: OptionsMap[T]
}

type Pets = SinglePet<keyof OptionsMap>[]

const petHome: Pets = [
    {
        type: 'dog',
        options: {
            name: '旺财',
            age: 2,
            bark: '汪汪汪',
            barkVolume: 2,
        },
    },
    {
        type: 'cat',
        options: {
            name: '小白',
            age: 1,
            favoriteToy: '玩具鸟',
            furLength: 'short',
        },
    },
]

数组里的每一项options,我们都是通过type推导出来的。


三、extends + 类型推导

extends和映射作用都是为了自动推导出另一个属性或者另一个类型,作用是一样的,具体使用哪种,可以根据项目而定


type PetKeys = 'dog' | 'cat' | 'fish' | 'bird'

type PetOptions<T extends PetKeys> = T extends 'dog'
    ? OptionsMap['dog']
    : T extends 'cat'
      ? OptionsMap['cat']
      : {
            text: number
        }

type OptionItem<T extends PetKeys> = PetOptions<T>

type Item<T extends PetKeys = PetKeys> = {
    type: T
    options: OptionItem<T>
}

type PetHome = Item[]

const petHome: PetHome = [
    {
        type: 'dog',
        options: {
            name: '旺财',
            age: 2,
            bark: '汪汪汪',
            barkVolume: 10,
        },
    },
    {
        type: 'bird',
        options: {
            text: 1,
        },
    },
    {
        type: 'cat',
        options: {
            name: '小白',
            age: 2,
            favoriteToy: '玩具鸟',
            barkVolume: 5,
            furLength: 'long',
        },
    },
]

上面这段代码主要是使用extend判断对应的类型,在项目中封装很常见,也利于后期拓展,其中核心代码为

type Item<T extends PetKeys = PetKeys> = { 
    type: T options: OptionItem<T> 
}

四、总结

类型推导可以不用,不能不会,有ts的项目必出现这种场景,当然也可以用AI 😂

最后告诉大家几个泛型、推导的注意事项

泛型参数,也就是本文中的T,如果一个使用项中的T已经明确类型了,其他地方也会跟着明确。
泛型参数一旦确定就不会再变,所有咱们如果使用默认值时,引用的类型必须要传递,否则就是按默认值算了。

最后祝大家日入过万,给俺点点关注、点点赞

uno.css.config.ts相关配置

2026年1月13日 16:40

UnoCSS配置项:

1. rules(自定义规则)定义新的类名规则,生成对应的css。
2. shortcuts(快捷方式)组合多个类名成一个简短的类名,提高效率。形式:字符串,数组,函数。
3. presets(预设)提供基础工具类功能扩展
presets工具1:persetWind3
  • 提供 Tailwind CSS 兼容的工具类
  • 支持响应式设计
  • 支持暗色模式
  • 提供颜色、间距、字体等预设
presets工具2:presetAttributify(属性化模式)
  • 支持将类名作为属性使用
  • 更符合 Vue/React 的写法习惯
presets工具3:presetIcons(图标预设)
presets工具4:presetTypography(排版预设)
  • 提供文章排版样式
  • 优化可读性
4.transformers - 转换器:处理特殊语法和指令
  • 1.transformerstransformerDirectives(指令转换器)
    • 支持 @apply 指令
    • 支持 @screen 指令
    • 支持 @variants 指令
  • 2.transformerstransformerVariantGroup(变体组转换器)
    • 支持变体组语法
    • 减少重复代码
5.theme - 主题配置(自定义主题变量,统一管理颜色、字体、间距等。)
  • 颜色配置
  • 断点配置
  • 字体配置
  • 间距配置
  • 圆角配置
6.safelist - 安全列表(强制包含某些类名,即使代码中未使用也会生成)
  • 字符串形式
  • 正则表达式形式
  • 函数形式
7.blocklist - 阻止列表(阻止某些类名被生成,即使代码中使用了也不会生成 CSS)
  • 阻止某些不安全的类名
  • 阻止已弃用的类名
  • 阻止特定模式的类名
8.preflights - 预设样式(添加全局 CSS 重置或基础样式。)
9.variants - 变体(自定义响应式、悬停等变体。)
10.layers - 图层(控制 CSS 的生成顺序,影响样式优先级。)
import { defineConfig,presetWind3,presetAttributify,presetIcons,presetTypography,transformerDirectives,transformerVariantGroup} from 'unocss'
export default defineConfig({
  // 所有配置项都在这里
  rules:[
      //静态规则
      [ly-mg,{margin:'10px'}],
      [ly-flex-center,{
          display:'flex'
      }],
      //动态规则(正则表达式)
      [/^ly-m-(\d+)$,([,d])=>({margin:`${d}px`})],
      [/^ly-p-(\d+)$/, ([, d]) => ({ padding: `${d}px` })],
      //css变量
      ['ly-primary',{
          'background-color':'var(--color-primary)',
          color:'var(--color-text)'
      }]
  ],
  shortcuts:{
      //组合多个类名
      "ly-common-boder":"ly-border-1px ly-border-solid ly-border-#D6DCE1",
      "ly-common-border-b": "ly-border-b-1px ly-border-b-solid ly-border-b-#D6DCE1",
      //组合布局类
       "ly-flex-center": "ly-flex ly-items-center ly-justify-center",
       "ly-flex-between": "ly-flex ly-items-center ly-justify-between",
       //组合卡片样式
       "ly-card": "ly-bg-white ly-rounded-8px ly-p-4 ly-shadow-md",
       // 组合按钮样式
       "ly-btn-primary": "ly-bg-blue-500 ly-text-white ly-px-4 ly-py-2 
        ly-rounded-4px hover:ly-bg-blue-600",
       // 数组形式:可以组合类名和对象
        "ly-card": [
          "ly-bg-white ly-rounded-8px ly-p-4",
          {
            'box-shadow': '0 2px 8px rgba(0,0,0,0.1)',
            'transition': 'all 0.3s ease'
          }
        ],

        "ly-btn": [
          "ly-px-4 ly-py-2 ly-rounded-4px",
          {
            'font-weight': '500',
            'cursor': 'pointer',
            'transition': 'all 0.2s'
          }
        ],
         // 函数形式:可以根据参数动态生成
         "ly-btn": (_, { theme }) => {
           return {
             'padding': '8px 16px',
             'border-radius': theme.borderRadius?.md || '4px',
             'background-color': theme.colors?.primary || '#409eff',
             'color': '#ffffff',
             'font-weight': '500',
             'cursor': 'pointer',
             'transition': 'all 0.2s',
             '&:hover': {
               'opacity': '0.8'
             }
           }
         },
     presets:[
         presetWind3({
             prefix:'ly-',//类名前缀
             dark:'class',//暗色模式:''class'| 'media''
             variablePrefix:'ly-',//css变量前缀
         }),
         presetAttributify({ 
             prefix: 'ly-',//类名前缀
             prefixedOnly: false,     // 是否只使用前缀
             nonValuedAttribute: true, // 支持无值属性
         }),
         presetIcons:({
              collections: {
                   // 使用 Iconify 图标集
                   carbon: () => import('@iconify-json/carbon/icons.json').then(i => i.
                default),
                   mdi: () => import('@iconify-json/mdi/icons.json').then(i => i.default),
                 },
                 extraProperties: {
                   'display': 'inline-block',
                   'vertical-align': 'middle',
                 }
             }),
         presetTypography(),
        ],
        transformers: [
          transformerDirectives(),
          transformerVariantGroup(),
        ],
        theme:{
            colors: {
              // 基础颜色
              primary: '#409eff',
              secondary: '#67c23a',
              danger: '#f56c6c',
              warning: '#e6a23c',
              info: '#909399',
              success: '#67c23a',

              // 嵌套颜色(支持色阶)
              brand: {
                50: '#f0f9ff',
                100: '#e0f2fe',
                200: '#bae6fd',
                300: '#7dd3fc',
                400: '#38bdf8',
                500: '#0ea5e9',
                600: '#0284c7',
                700: '#0369a1',
                800: '#075985',
                900: '#0c4a6e',
              },

              // 使用 CSS 变量
              primary: 'var(--color-primary)',
              secondary: 'var(--color-secondary)',
            },
            //断点
             breakpoints: {
               xs: '480px',   // 超小屏幕:小手机(竖屏)
               sm: '640px',   // 小屏幕:大手机(横屏)、小平板
               md: '768px',   // 中等屏幕:平板(竖屏)
               lg: '1024px',  // 大屏幕:平板(横屏)、小笔记本
               xl: '1280px',  // 超大屏幕:桌面显示器
               '2xl': '1536px', // 超超大屏幕:大桌面显示器
             },
           },
           fontFamily: {
               sans: ['Inter', 'system-ui', 'sans-serif'],
               mono: ['Fira Code', 'monospace'],
               serif: ['Georgia', 'serif'],
             },

             fontSize: {
               xs: ['12px', { lineHeight: '16px' }],
               sm: ['14px', { lineHeight: '20px' }],
               base: ['16px', { lineHeight: '24px' }],
               lg: ['18px', { lineHeight: '28px' }],
               xl: ['20px', { lineHeight: '30px' }],
               '2xl': ['24px', { lineHeight: '32px' }],
             },
           spacing: {
              xs: '4px',
              sm: '8px',
              md: '16px',
              lg: '24px',
              xl: '32px',
              '2xl': '48px',
              '3xl': '64px',
           },  
           borderRadius: {
              none: '0',
              sm: '2px',
              md: '4px',
              lg: '8px',
              xl: '12px',
              '2xl': '16px',
              full: '9999px',
           },
        },
        safelist: [
        'ly-flex',
        'ly-grid',
        'ly-hidden',
        'ly-block',
        // 匹配所有颜色变体
        /^ly-bg-(red|green|blue|yellow|purple)-\d+$/,
        /^ly-text-(xs|sm|base|lg|xl|2xl)$/,

        // 匹配所有间距
        /^ly-(p|m|px|py|mx|my)-(\d+)$/,
        //函数
        (matcher) => {
              // 包含特定前缀的类名
              if (matcher.startsWith('ly-'))
                return matcher

              // 动态生成的类名
              if (matcher.includes('dynamic'))
                return matcher
            },
        ],
        //阻止列表
        blocklist: [
          // 字符串
          'ly-hidden',
          'ly-block',

          // 正则表达式
          /^ly-bg-red-\d+$/,  // 阻止所有红色背景
          /^ly-text-danger$/,  // 阻止危险色文字

          // 函数
          (matcher) => {
            if (matcher.includes('deprecated'))
              return true
          },
        ],
        preflights: [
          {
            getCSS: () => `
              * {
                box-sizing: border-box;
                margin: 0;
                padding: 0;
              }

              body {
                font-family: -apple-system, BlinkMacSystemF
                line-height: 1.5;
                -webkit-font-smoothing: antialiased;
                -moz-osx-font-smoothing: grayscale;
              }

              html {
                scroll-behavior: smooth;
              }
            `
          },
          {
            getCSS: ({ theme }) => `
              :root {
                --color-primary: ${theme.colors?.primary ||
                --color-secondary: ${theme.colors?.secondar
                --spacing-base: ${theme.spacing?.md || '16p
              }

              [data-theme="dark"] {
                --color-primary: #5dade2;
                --color-secondary: #7dcea0;
              }
            `
          }
        ],
        variants: [
          // 自定义 data 属性变体
          {
            name: 'data-active',
            match(matcher) {
              const prefix = 'data-active:'
              if (!matcher.startsWith(prefix))
                return
              return {
                matcher: matcher.slice(prefix.length),
                selector: s => `[data-active] ${s}`,
              }
            },
          },

          // 自定义响应式断点
          {
            name: 'mobile',
            match(matcher) {
              if (!matcher.startsWith('mobile:'))
                return
              return {
                matcher: matcher.slice(7),
                handle: (input, { theme }) => {
                  const breakpoint = theme.breakpoints?.sm || '640px'
                  return {
                    parent: `@media (max-width: ${breakpoint})`,
                    ...input,
                  }
                },
              }
            },
          },
        ],
        layers: {
          components: -1,    // 组件层(最低优先级)
          utilities: 0,       // 工具类层(默认)
          shortcuts: 1,      // 快捷方式层
          preflights: 2,     // 预设样式层(最高优先级)
        }
     }
})
<template>
    <div>
        <div class="ly-m-50">rules正则表达式用法unocss</div>
        <div class="ly-mg">rules自定义静态用法</div>
        <div class="ly-primary">rulesCss的变量</div>
        <div class="ly-common-border">边框容器</div>
        <div class="ly-flex-center">居中布局</div>
        <div class="ly-card">卡片样式</div>
        <button class="ly-btn-primary">主要按钮</button>
        <div></div>
        <div class="ly-flex ly-items-center ly-p-4">
           presetAttributify标准方式
        </div>
        <div lyFlex lyItemsCenter lyP="4">
          presetAttributify属性化方式
        </div>
        <div class="ly-flex" lyItemsCenter lyP="4">
          presetAttributify混合使用
        </div>
         <div class="i-carbon-home">使用presetIcons图标</div>
         <div class="i-mdi-account">使用presetIcons图标</div>
        <div>
             <div>presetTypography(排版预设)</div>
             <article class="prose">
               <h1>标题</h1>
               <p>段落内容</p>
             </article>
        </div>
        <div>
             <div class="custom-card">卡片</div>
        </div>
        <div class="hover:ly-bg-blue-500 hover:ly-text-white hover:ly-shadow-md">
          transformerVariantGroup标准悬停效果
        </div>
        <div class="hover:(ly-bg-blue-500 ly-text-white ly-shadow-md)">
          transformerVariantGroup标准悬停效果变体悬停效果
        </div>
        <div class="hover:(ly-bg-blue-500 ly-text-white) focus:(ly-border-2 
        ly-border-blue-500)">
          transformerVariantGroup多个变体
        </div>
         <div class="ly-bg-primary">主色调背景</div>
         <div class="ly-text-danger">危险色文字</div>
         <div class="ly-border-warning">警告色边框</div>

         <!-- 使用色阶 -->
         <div class="ly-bg-brand-500">品牌色 500</div>
         <div class="ly-bg-brand-600">品牌色 600</div>
          <!-- 响应式宽度 -->
         <div class="ly-w-full md:ly-w-1/2 lg:ly-w-1/3">
           响应式布局
         </div>

         <!-- 响应式字体 -->
         <div class="ly-text-sm md:ly-text-base lg:ly-text-lg">
           响应式字体
         </div>
          <div class="ly-font-sans">无衬线字体</div>
         <div class="ly-font-mono">等宽字体</div>
         <div class="ly-text-xs">小号字体</div>
         <div class="ly-text-base">基础字体</div>
         <div class="ly-p-xs">小内边距</div>
        <div class="ly-p-md">中等内边距</div>
        <div class="ly-m-lg">大外边距</div>
    </div>
</template>
<style scoped>
.custom-card {
  @apply ly-bg-white ly-rounded-8px ly-p-4;
  @apply ly-shadow-md hover:ly-shadow-lg;
  @apply transition-all duration-300;
}
/* 响应式 */
@media (min-width: 768px) 
  .custom-card {
    @apply ly-p-6;
  }
}

/* 使用 @screen */
.custom-card {
  @screen md {
    @apply ly-p-6;
  }
}
</style>
总结格式:
import { defineConfig, presetWind3, transformerDirectives, transformerVariantGroup,presetIcons,presetTypography } from "unocss";

export default defineConfig({
  //组合式(快捷方式)
  shortcuts: {
  },
  //自定义(样式)
  rules: [
  ],
   //预设(工具与功能)
  presets: [
    //响应设计
    presetWind3({ 
      prefix: "ly-",//类名前缀
      dark: "class",//暗色模式:'class' | 'media'
      variablePrefix: "--ly-",//变量前缀
    }),
    presetIcons(), //图标预设
    presetTypography(),//排版预设
  ],
  //转换器(转换器)
  transformers: [
    transformerDirectives(), //指令转换器
    transformerVariantGroup() //变体组转换器
  ],
  // 配置项
  theme: {
    // 颜色
    colors: {
      // 配置项
    },
    //断点配置
    breakpoints: {
      // 配置项
      xs: '480px',   // 超小屏幕:小手机(竖屏)
      sm: '640px',   // 小屏幕:大手机(横屏)、小平板
      md: '768px',   // 中等屏幕:平板(竖屏)
      lg: '1024px',  // 大屏幕:平板(横屏)、小笔记本
      xl: '1280px',  // 超大屏幕:桌面显示器
     '2xl': '1536px', // 超超大屏幕:大桌面显示器
    },

    //间距
    spacing: {
      // 配置项
    },
    //字体
    fontFamily: {
      // 配置项
    },
    fontSize: {
      // 配置项
    },
    //圆角
    borderRadius: {
      // 配置项
    },
    //阴影
    boxShadow: {
      // 配置项
    },
  },
//安全列表
safelist: [
  // 配置项
],
// 阻止列表
blocklist: [
  // 配置项
],
//预设样式
preflights: [
  // 配置项
],
//变体
variants: [
  // 配置项
],
//layers 图层配置,三个不可少
layers: {
  base: 1,
  components: 2,
  utilities: 3
}
});


AI写代码记录

作者 _瑶瑶_
2026年1月13日 16:36

直接使用AI写代码目前应该算是挺火的,cursor感觉无所不能,Trae也在齐头并进,虽然说差点,但是满足日常开发绰绰有余。

但是在使用中我有一些自己的观点:

(1)使用AI协助开发是最优解,依赖AI开发有待商榷
(2)开发经验浅的人不适合直接使用AI编程
(3)非开发人员(比如美工)尽量不要直接用AI设计界面以及功能

在开发中,我一直秉承着老式开发原则,除了重复性功能(比如:登录防抖节流等)、数据处理(将接口返回的数据重构为其它的数据机构)之外,其实很少用到AI,更多的把AI当做一个知识库,忘记了的查询一下,未接触过的了解一下,仅此而已。

为什么突然想浅记一下,因为最近有两起使用过程不那么愉快的经历。

事件一:

公司让美工设计界面,美工指挥cursor直接生成了一套没有连接后端数据的一套完整的系统。在不了解公司使用的vue开发时,cursor先用nextjs构建了一套服务端渲染的系统,在想和公司之前做的项目融合的时候发现完全不一样。

在了解公司使用的技术栈之后,重新使用vue生成了一套一模一样的完整的系统,可能是不了解技术,描述的不够准确,生成的内容不是那么恰当。

下面举一个例子可能更明白一点。

总共有4个路由界面,4个路由的结构为:

  {
    path: "/homePage",
    name: "homePage",
    component: () => import("@/views/HomePage.vue"),
  },
  {
    path: "/userPage",
    name: "userPage",
    component: () => import("@/views/UserPage.vue"),
  },
  {
    path: "/messagePage",
    name: "messagePage",
    component: () => import("@/views/MessagePage.vue")
  },
  {
    path: "/articlePage",
    name: "articlePage",
    component: () => import("@/views/ArticlePage.vue")
  }

每一个页面vue文件的代码为

<template>
    <Header />
    ……
</template>

每一个页面的布局为 image.png

嗯……看到运行效果的时候其实感觉不出来一点不对,觉得设计的蛮好的,但是准备开发的时候发现,header组件是每一个页面都有的,而且在其中会需要项目的信息,所以就在Header中调用了项目信息。这些信息每一个页面都需要,但是这样写每个页面都获取不到项目的信息。而且切换页面的时候,header中会重新调接口,事情彷佛有些不对……

既然用了vue,vue也做了很多单页面应用,而且这个项目结构来看的话就是个单页面应用呀,但是美工的描述和AI的生成并没有意识到这个问题,个人认为正确的结构应该是下面这样的。

路由结构

 {
    path: "/homePage",
    component: Layout,
    children: [{
      path: "",
      name: 'homePage',
      component: () => import("@/views/secondVersion/HomePage.vue"),
    }]
  },
  {
    path: "/userPage",
    component: Layout,
    children: [{
      path: "",
      name: 'userPage',
      component: () => import("@/views/UserPage.vue"),
    }]
  },
  {
    path: "/messagePage",
    component: Layout,
    children: [{
      path: "",
      name: "messagePage",
      component: () => import("@/views/MessagePage.vue")
    }]
  },
  {
    path: "/articlePage",
    component: Layout,
    children: [{
      path: "",
      name: "articlePage",
      component: () => import("@/views/ArticlePage.vue")
    }]
  },

页面结构应该是这样的

image.png

这样每一次的路由切换只切换router-view的部分

所以不了解项目结构的美工不建议直接使用AI生成页面并直接让前端继续开发功能,因为可能会有很多地方不合理,有时候修改的时间甚至大于直接开发的时间。

事件二

公司同事很喜欢用AI直接写功能,但是可能自己的基础不是很牢固,平时做页面、写功能都没有问题,但是稍微一复杂的东西就使用AI实现。

有一个这个功能,类似于购物车数量功能,可加(一次加1)可减(一次减1)可输入,也可以长按加(一次加5)长按减(一次减5)。

因为数量有范围,范围是1-99,如果没有值的情况下,默认显示99,(不知道为什么会有这个设定)

我模拟的部分(类似)AI给的代码如下:

const max = 99;
const numValue = ref(0)
const defaultValue = ref(99)

const operate = (type) => {
    let tempValue = numValue.value || defaultValue;
    if (type == 1) {
        if (tempValue >= max) {
            // 已经是最大值不能设置
        }
        tempValue += 1;
    }
    ……
}

其它功能都好了,就是值为0的时候按加号会提示“已经是最大值不能设置”。

按说这个问题很好解决,虽然我贴的内容很少,但是同事就一直没有解决,可能一天工作也很累,看的眼睛都花了,所以才没有解决。但是我认为如果不是AI生成的,是自己写的是不是就不会出现这个问题。

毕竟自己写的话,在0是有效值的时候是不会用这个值为0的变量去进行||运算的。

总结

以上就是最近的使用过程,不能否认AI带来的优点是使工作更方便的更高效,但是不合理的使用也会带来很多麻烦。

前端防止重复支付解决方案

作者 sophie旭
2026年1月13日 16:36

背景

这期并不是什么高大上的主题,但是对于支付业务却是尤为重要,那就是如何在前端角度防止重复支付,在这边把我的解决方案记录下来,也想剖析一下里面的细节,同时也分享给大家。

解决方案

// 假设使用 lodash 的 throttle 函数
import { throttle } from 'lodash';

// 定义 loading 状态(Vue 中可放在 data/setup 中)
let isPayLoading = false;

// 支付核心函数
const goToPay = async () => {
  // 1. Loading 状态锁:拦截重复请求
  if (isPayLoading) return;
  
  try {
    // 2. 开启 loading(按钮置灰、显示加载动画)
    isPayLoading = true;
    
    // 3. 执行支付请求(示例接口)
    const res = await fetch('/api/pay', {
      method: 'POST',
      body: JSON.stringify({ orderId: '123456' })
    });
    
    const data = await res.json();
    if (data.code === 200) {
      alert('支付成功!');
    } else {
      alert('支付失败:' + data.msg);
    }
  } catch (err) {
    alert('支付请求异常:' + err.message);
  } finally {
    // 4. 无论成功/失败,关闭 loading
    isPayLoading = false;
  }
};

// 节流包装支付函数:拦截快速点击
const throttlePay = throttle(
  () => {
    goToPay();
  },
  2000,
  { leading: true, trailing: false }
);

// 支付按钮点击事件绑定这个节流后的函数
// <button onclick="throttlePay()" :disabled="isPayLoading">支付</button>

一、先理清整体逻辑

你这段代码的核心是:

  • throttle 节流函数包装支付触发逻辑,设置 2000ms 内只能触发一次,且 leading: true(立即触发第一次)、trailing: false(不触发最后一次)。
  • 在被调用的 goToPay 内部还加了 loading 状态控制。 两者结合形成了“双重保险”来防止重复支付,我们先拆解各自的作用,再分析组合的巧妙性。

二、防止重复支付的核心原因

重复支付的本质是:用户短时间内多次点击“支付”按钮,导致多次触发支付请求,后端可能接收到多个支付指令,最终造成重复扣款。而节流 + loading 的组合从不同维度阻断了这个问题:

1. 节流(throttle)的作用:阻断“快速连续触发”
  • 底层原因:节流函数的核心是「时间窗口控制」—— 设定 2000ms 为一个时间窗口,窗口内无论触发多少次,只会执行一次(leading: true 保证第一次点击立即响应,trailing: false 避免窗口结束后额外触发)。 比如用户疯狂点击支付按钮,1秒内点了5次,节流会确保只有第一次点击触发 goToPay,剩下4次直接被拦截,从「触发频率」上限制了重复请求。
  • 局限性:节流只控制“触发次数”,但无法感知支付请求的「执行状态」(比如请求是否成功、是否还在处理中)。如果支付请求本身耗时超过 2000ms(比如网络慢),节流窗口过期后,用户再次点击仍可能触发重复请求。
2. Loading 控制的作用:阻断“请求未完成时的触发”

goToPay 中加 loading 通常是这样的逻辑:

// 示例:goToPay 核心逻辑
let isLoading = false; // 全局/局部的 loading 状态
function goToPay() {
  // 1. 如果正在加载中,直接返回,不执行后续逻辑
  if (isLoading) return;
  
  // 2. 开启 loading(按钮置灰、显示加载动画)
  isLoading = true;
  
  // 3. 执行支付请求
  payRequest()
    .then(res => {
      // 支付成功逻辑
    })
    .catch(err => {
      // 支付失败逻辑
    })
    .finally(() => {
      // 4. 请求完成(成功/失败),关闭 loading
      isLoading = false;
    });
}
  • 底层原因isLoading 是一个「状态锁」—— 支付请求发起前,先检查锁的状态:
    • 锁为 true(请求中):直接返回,不发起新请求;
    • 锁为 false(无请求):先上锁,再发起请求,请求结束后解锁。 这从「请求状态」上阻断了重复触发,无论节流窗口是否过期,只要请求没完成,就无法再次执行支付逻辑。

三、设计上的巧妙之处

节流 + loading 是“互补式”设计,完美解决了单一方案的不足,核心巧妙点有 3 个:

1. 分层防护:前端“触发层” + “执行层”双重拦截
  • 节流属于「触发层防护」:拦截用户“无意识的快速点击”(比如手抖点了2次),是“前置拦截”,不进入业务逻辑;
  • Loading 属于「执行层防护」:拦截用户“有意识的重复点击”(比如支付请求卡顿时,用户多次点击),是“后置拦截”,即使节流失效(比如窗口过期),也能通过状态锁阻断。 两者结合,覆盖了“快速点击”和“请求中点击”两种最常见的重复支付场景。
2. 体验与安全兼顾
  • leading: true 保证用户第一次点击能立即响应,不会因为节流导致“点击没反应”的糟糕体验;
  • trailing: false 避免“用户点击后等待一段时间,又莫名触发支付”的问题(比如用户点击后取消支付,结果2秒后又触发);
  • Loading 不仅是防重复的逻辑,还能给用户视觉反馈(按钮置灰、加载动画),让用户知道“系统正在处理”,减少重复点击的冲动。
3. 容错性强:适配不同网络/场景
  • 节流依赖“固定时间窗口”,但支付请求的耗时是不确定的(网络快可能100ms完成,网络慢可能5秒);
  • Loading 不依赖时间,只依赖“请求完成状态”,无论请求耗时多久,只要没完成就不会重复触发,完美适配不同网络环境;
  • 即使节流函数出问题(比如参数配置错误、节流库异常),Loading 仍能独立起到防重复支付的核心作用,是“兜底保障”。
4. 2秒时长的核心价值

我把节流时长设为2秒,而非1秒或更短,本质是给“异步loading”留足“兜底容错时间”

1. 为什么短时长(比如500ms)会有重复点击风险?

POS机和普通浏览器不同,它的特点是:

  • 硬件性能弱:CPU/内存有限,JS执行、网络请求的耗时会比普通设备长;
  • 异步操作延时大:goToPay里的loading是异步的(比如发起支付请求、更新DOM状态),可能出现“节流窗口过期了,但loading还没来得及关闭”的情况:
    时间线(假设节流设500ms):
    0ms → 用户点击,节流触发goToPay,loading开始异步开启(POS机卡,loading状态还没更新完成);
    500ms → 节流窗口过期,节流逻辑允许再次触发;
    600ms → 用户再次点击(以为没反应),此时loading还没完全开启(isLoading还是false),直接触发重复支付;
    
2. 2秒时长的“兜底作用”

2秒是一个「足够覆盖POS机异步操作最大延时」的安全值:

  • 即使POS机性能差,loading的异步开启/关闭、支付请求的初始耗时,也几乎能在2秒内完成;
  • 就算loading因为设备卡顿稍有延迟,2秒的节流窗口也能“撑到loading状态生效”,避免“节流过期但loading没锁”的漏洞。

总结

核心关键点回顾:

  1. 双重防护逻辑:节流控制「触发频率」(防快速点击),loading 控制「请求状态」(防请求中点击),覆盖所有重复支付场景;
  2. 巧妙的设计互补:节流保证“点击即时响应”的体验,loading 作为“兜底保障”适配不确定的请求耗时;
  3. 体验与安全兼顾:loading 既是防重复的逻辑锁,也是给用户的视觉反馈,减少重复点击的动机。

节流防抖 傻傻分不清楚

一、先明确核心需求:支付场景的本质要求

支付按钮的核心诉求是:

  1. 用户点击后必须立即执行支付逻辑(不能等、不能吞掉用户的点击);
  2. 短时间内(比如2秒)多次点击,只能执行一次(防止重复支付);
  3. 2秒后再次点击,仍能正常执行(用户第一次支付失败,2秒后可以重新点击)。

这三个诉求是判断用节流还是防抖的关键,我们先对比两者的核心差异:

特性 节流 (throttle) 防抖 (debounce)
核心逻辑 「固定时间窗口内只能执行一次」,像水流一样匀速通过 「等待最后一次触发后,延迟执行」,像弹簧一样松手才回弹
触发时机 窗口内第一次触发(leading: true)立即执行 只有停止触发后,等待指定时间才执行
多次触发的结果 窗口内只执行一次,窗口过期后可再次执行 只要一直在触发,就永远不执行

二、为什么这里用节流,而不是防抖?

1. 防抖完全不符合支付场景的核心诉求

假设把代码中的 throttle 换成 debounce,参数同样设为2秒:

// 错误示例:用防抖包装支付函数
const debouncePay = debounce(() => {
  goToPay()
}, 2000);

会出现两个致命问题:

  • 问题1:用户正常点击(只点1次)
    防抖会等待2秒后才执行 goToPay —— 用户点击支付按钮,看到页面没反应,会误以为点击失效,大概率会再次点击,反而加剧重复点击的问题;
  • 问题2:用户连续点击(点多次)
    防抖会“重置等待时间”—— 比如用户1秒内点了3次,防抖会从最后一次点击开始重新计时2秒,只要用户不停点击,支付逻辑就永远不会执行,直接导致支付功能失效。

简单说:防抖的核心是「等用户停手后再执行」,而支付需要「用户动手就立即执行,且短时间内只执行一次」,两者的核心逻辑完全相悖。

2. 节流完美匹配支付场景的诉求

你代码中的节流配置 { leading: true, trailing: false } 刚好命中支付需求:

  • leading: true:用户第一次点击时,立即执行支付逻辑(满足“点击必响应”);
  • trailing: false:2秒窗口内后续的点击都被拦截(满足“短时间内只执行一次”);
  • 2秒窗口过期后,再次点击会重新触发(满足“失败后可重新支付”)。

三、补充:什么时候才会用防抖?

防抖的适用场景是「需要等待用户操作结束后再执行」的场景,比如:

  1. 搜索框输入联想(等用户输完关键词,再发请求查联想词,避免边输边发请求);
  2. 窗口大小调整(等用户拖完窗口,再执行布局重绘,避免频繁重绘);
  3. 手机号/验证码输入校验(等用户输完,再校验格式,避免边输边提示错误)。

这些场景的核心是“不着急执行,等用户停手再执行”,和支付“必须立即执行”的诉求完全相反。

总结

核心关键点回顾:

  1. 核心逻辑差异:节流是「固定时间内只执行一次」,保证触发即响应;防抖是「等最后一次触发后延迟执行」,会吞掉中间的触发;
  2. 场景匹配度:支付需要“点击立即执行+短时间防重复”,节流刚好满足,防抖会导致“点击不立即响应”甚至“永远不执行”;
  3. 记忆技巧节流=“控制频率”(多久执行一次),防抖=“等待结束”(停手才执行),支付场景要“控频率”而非“等结束”。

防抖/节流 设计的巧妙之处

一、核心设计巧思:用「状态管理」驯服高频触发

防抖和节流的本质,是通过管理“唯一状态” 把「无规律的高频触发」转化为「可控的低频执行」,这是最核心的巧妙之处:

1. 防抖:用「定时器状态」实现“等待最后一次”
  • 问题本质:高频触发(比如搜索框输入)如果每次都执行,会频繁发请求/渲染,浪费性能;
  • 巧妙设计:只维护一个「定时器ID」状态,每次触发时:
    • 先清除旧定时器(重置等待时间)—— 相当于“电梯每次按关门键都重新等”;
    • 再创建新定时器(设定新的等待时间)—— 只有最后一次触发后,定时器没被清除,才会执行;
  • 为什么妙:用「一个变量+两个操作(清/设定时器)」就实现了“等待用户操作结束”的核心诉求,没有多余逻辑,状态管理极简。
2. 节流:用「开关/时间戳状态」实现“频率控制”
  • 问题本质:高频触发(比如滚动/点击)需要保证“每隔固定时间只执行一次”,既不浪费性能,又能及时响应;
  • 巧妙设计:只维护一个「开关(canRun)」或「时间戳(lastTime)」状态,每次触发时:
    • 先判断状态(开关是否关闭/时间差是否够)—— 相当于“闸机先看是否在冷却期”;
    • 只有状态满足(开关开/时间差够),才执行目标函数,并更新状态(关开关/更新时间戳);
    • 冷却期结束后,自动恢复状态(开开关);
  • 为什么妙:用「一个布尔值/数字」就精准控制了执行频率,没有复杂的计数/队列,逻辑极简且性能开销几乎为0。

二、场景适配巧思:既解决技术问题,又贴合「用户行为」

防抖和节流的设计不只是“技术层面的优化”,更精准适配了「人类操作的特点」,这是容易被忽略的巧妙之处:

1. 防抖:贴合“用户需要完成操作后再反馈”的行为
  • 比如搜索框输入:用户不会只输一个字就等结果,而是输完一整句话才需要联想;
  • 防抖的“等待最后一次触发”刚好贴合这个行为—— 既避免了“边输边发请求”的性能浪费,又保证“用户输完就出结果”的体验;
  • 对比笨办法(比如每输入一个字都发请求):防抖既不牺牲体验,又能减少90%以上的无效请求。
2. 节流:贴合“用户需要即时反馈,但不能太频繁”的行为
  • 比如支付按钮点击:用户点击后需要“立即响应”(不能等),但又要防止“手抖点多次”;
  • 节流的“冷却期控制”刚好贴合这个行为—— 第一次点击立即执行(满足“即时反馈”),冷却期内拦截重复点击(满足“防重复”),冷却期后可重新执行(满足“失败后重试”);
  • 对比笨办法(比如点击后禁用按钮):节流不用修改DOM状态,只是“逻辑层面的频率控制”,更通用(可复用在滚动、resize等无DOM的场景)。
3. 可配置化扩展:兼顾“通用性”和“个性化”

优秀的防抖/节流实现(比如lodash版)还会设计leading(是否立即执行)、trailing(是否延迟执行)等参数,比如:

  • 防抖加immediate: true:可适配“第一次触发立即执行,后续触发重置”的场景(比如弹窗关闭按钮);
  • 节流加leading: false, trailing: true:可适配“滚动结束后再执行”的场景;
  • 为什么妙:基础逻辑不变,通过简单参数配置就能适配不同场景,做到“一次编写,多处复用”,符合「开闭原则」(对扩展开放,对修改关闭)。

三、实现细节巧思:最小侵入性+无副作用

防抖/节流的设计还藏着很多“细节上的巧思”,保证了函数的健壮性和易用性:

1. 保留this和参数:无副作用封装
// 核心代码片段
return function(...args) {
  fn.apply(this, args); // 关键:绑定原函数的this和参数
};
  • 为什么妙:如果直接调用fn(),会丢失原函数的this(比如DOM事件中的this指向元素)和参数(比如事件对象e);
  • apply(this, args)保留上下文和参数,让防抖/节流函数“透明”包裹目标函数,原函数的行为完全不变—— 这是“无副作用封装”的关键,也是能通用的核心。
2. 支持取消:应对极端场景
// 防抖扩展:添加取消功能
function debounce(fn, delay) {
  let timer = null;
  const debounced = function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
  // 新增取消方法
  debounced.cancel = function() {
    clearTimeout(timer);
    timer = null;
  };
  return debounced;
}
  • 为什么妙:比如用户输入一半突然不想搜了,点击取消按钮,可通过debounced.cancel()清除定时器,避免“已经取消操作但还执行函数”的问题;
  • 这种设计让函数更健壮,能应对“中途终止”的极端场景。
3. 无全局污染:状态私有化
  • 防抖/节流的核心状态(定时器ID、开关、时间戳)都定义在「闭包」中,而不是全局变量;
  • 为什么妙:每个被防抖/节流包装的函数,都有自己独立的状态,不会互相干扰(比如同时包装两个按钮的点击事件,各自的冷却期互不影响);
  • 对比用全局变量存状态:闭包让状态私有化,避免了“全局变量冲突”的问题,符合「模块化」设计思想。

总结

防抖/节流设计的核心巧妙点回顾:

  1. 极简状态管理:只用一个核心状态(定时器/开关/时间戳),就驯服了高频触发,逻辑简单且性能开销极低;
  2. 贴合用户行为:不是单纯的技术优化,而是精准适配人类操作特点(防抖等结束、节流控频率),兼顾性能和体验;
  3. 无侵入性封装:保留原函数的this和参数,状态私有化,支持扩展(取消、配置参数),做到“通用、无副作用、可扩展”。

防抖/节流 实现如何快速记住

记「极简固定模板」(只记核心结构,不用记细节)

我帮你提炼了“万能模板”,核心代码只有几行,记模板比记零散代码容易10倍:

1. 防抖(debounce)模板(核心:重置定时器)

模板逻辑

  • 初始化一个定时器变量(存定时器ID);
  • 每次触发函数时,先清掉旧定时器(重置等待时间);
  • 再设新定时器,延迟后执行目标函数。

手写代码(带注释,只记标★的核心行)

// 防抖函数:fn=目标函数,delay=延迟时间
function debounce(fn, delay) {
  let timer = null; // ★ 唯一状态:定时器(电梯的“等待倒计时”)
  
  // 返回包装后的函数(用户每次点击/触发都会执行这个函数)
  return function(...args) {
    // ★ 核心1:触发时先清旧定时器(按关门键,重置2秒等待)
    clearTimeout(timer);
    
    // ★ 核心2:设新定时器,延迟后执行目标函数(等2秒,没人按就关门)
    timer = setTimeout(() => {
      fn.apply(this, args); // 保留this和参数(适配实际场景)
    }, delay);
  };
}

简化记忆:防抖=「清旧定时器→设新定时器」,就这两步核心,其他都是适配性代码(apply是为了绑定this,可后期补)。

2. 节流(throttle)模板(核心:判断冷却期)

模板逻辑

  • 初始化一个“是否可执行”的开关(或记录上次执行时间);
  • 触发时先判断:如果在冷却期(开关关/时间没到),直接返回;
  • 如果不在冷却期:先关掉开关(进入冷却),执行目标函数,延迟后打开开关(结束冷却)。

手写代码(两种常见写法,记一种就行,推荐第一种)

// 节流函数:fn=目标函数,delay=冷却时间
function throttle(fn, delay) {
  let canRun = true; // ★ 唯一状态:冷却开关(闸机的“是否可用”)
  
  return function(...args) {
    // ★ 核心1:冷却期内,直接返回(闸机不可用,刷了也白刷)
    if (!canRun) return;
    
    // ★ 核心2:关闭开关,进入冷却(闸机用一次,锁2秒)
    canRun = false;
    // 执行目标函数(闸机开门)
    fn.apply(this, args);
    
    // ★ 核心3:延迟后打开开关(2秒后闸机恢复可用)
    setTimeout(() => {
      canRun = true;
    }, delay);
  };
}

简化记忆:节流=「判断开关→关开关→执行→延迟开开关」,核心是“开关控制冷却期”。


补充:节流的另一种写法(按时间戳,逻辑一致)

如果面试官让用时间戳写,只是“冷却期判断方式”变了,核心还是“控冷却”:

function throttle(fn, delay) {
  let lastTime = 0; // 上次执行时间(替代canRun)
  
  return function(...args) {
    const now = Date.now();
    // 核心:判断当前时间 - 上次执行时间 ≥ 延迟时间(冷却期过了)
    if (now - lastTime >= delay) {
      fn.apply(this, args);
      lastTime = now; // 更新上次执行时间(关开关)
    }
  };
}

记忆:时间戳写法只是把“开关”换成了“时间差判断”,核心还是“冷却期内不执行”。

四、第三步:记「唯一差异点」(避免混淆)

防抖和节流的代码就一个核心区别,记死这一点,永远不会混:

对比项 防抖 (debounce) 节流 (throttle)
核心操作 每次触发都「清定时器」(重置) 触发时先「判断冷却期」(拦截)
定时器作用 延迟执行目标函数 延迟结束冷却期
执行时机 停止触发后延迟执行 触发时立即执行(冷却期过的话)

一句话总结差异

  • 防抖:「先清后设」定时器(清旧的,设新的);
  • 节流:「先判断后执行」(判断能不能执行,能就执行+锁冷却)。

五、记忆技巧:3分钟快速默写训练(每天练1次,3天就记住)

不用死背,按这个步骤练,每次只花3分钟:

  1. 第一步(1分钟):先默念场景→防抖=电梯、节流=闸机;
  2. 第二步(1分钟):写核心模板(只写标★的行):
    • 防抖:let timer → clearTimeout(timer) → timer = setTimeout(...);
    • 节流:let canRun=true → if(!canRun)return → canRun=false → setTimeout(开canRun);
  3. 第三步(1分钟):补全适配代码(apply(this, args))。

练3次后,你会发现:不用记完整代码,只要写出核心逻辑,剩下的都是“填空”。

六、完整可运行代码(对照练)

最后给你完整的防抖+节流代码,练的时候对照:

// 1. 防抖函数(带立即执行可选参数,进阶版,先记基础版,再补这个)
function debounce(fn, delay, immediate = false) {
  let timer = null;
  return function(...args) {
    // 每次触发清旧定时器
    clearTimeout(timer);
    
    // 立即执行版(可选,基础版不用记这个)
    if (immediate && !timer) {
      fn.apply(this, args);
    }
    
    // 设新定时器
    timer = setTimeout(() => {
      fn.apply(this, args);
      timer = null; // 重置timer,方便immediate判断
    }, delay);
  };
}

// 2. 节流函数(开关版,最易记)
function throttle(fn, delay) {
  let canRun = true;
  return function(...args) {
    if (!canRun) return;
    canRun = false;
    fn.apply(this, args);
    setTimeout(() => {
      canRun = true;
    }, delay);
  };
}

// 测试用例(练完可以跑一下,加深印象)
// 防抖测试:连续点击,只在最后一次点击后1秒执行
const debounceClick = debounce(() => console.log('防抖执行'), 1000);
// 节流测试:连续点击,每1秒执行一次
const throttleClick = throttle(() => console.log('节流执行'), 1000);

总结

核心关键点回顾:

  1. 记锚点:防抖=搜索框输入(重置等待),节流=闸机(冷却期),先想场景再想代码;
  2. 记模板:防抖核心是「清旧定时器→设新定时器」,节流核心是「判断开关→关开关→延迟开开关」;
  3. 记差异:防抖是“重置时间”,节流是“控制频率”,核心操作一个清定时器、一个判断冷却期。

EasyDebug v0.0.4 重磅更新:原生 Http 支持 + 全新日志控制台

作者 JasonBoolean
2026年1月13日 16:10

EasyDebug v0.0.4 重磅更新:原生 Http 支持 + 全新日志控制台 🐞

EasyDebug0.0.1-3 原文地址

大家好!EasyDebug 刚刚发布了 v0.0.4 版本。这就来和大家同步一下这次的重大更新内容!🚀

这次更新主要解决了两个痛点:

  1. 除了 Dio,很多同学还在用原生的 http 库,以前没法抓包。
  2. 除了看网络请求,我们经常还想看 print 的普通日志,以前得连电脑看控制台。

现在,这些问题都解决啦!✨


🌟 1. 新增:普通日志 (General Logs) 控制台

在这个版本中,我加入了一个全新的  "Logs"  面板。 它不仅可以显示网络请求,还能自动捕获你项目中的 debugPrint 和 EasyDebug.log 输出。

核心特性:

  • 自动拦截:只要初始化了插件,标准的 debugPrint 会被自动捕获。
  • 智能着色:自动识别日志中的 Error, Warning 等关键词,并用 红/橙/蓝 高亮显示,像在 IDE 里一样清晰。
  • 一键复制:点击任意一条日志即可复制内容,方便分享报错信息。

3.png


🌐 2. 新增:原生 http 库支持

以前 EasyDebug 只支持 Dio,现在如果您使用 http package,也能享受抓包功能了! 我们提供了一个 

EasyDebugHttpClient 包装器,使用非常简单:

import 'package:http/http.dart' as http;
import 'package:easy_debug/easy_debug.dart';
// 1. 包装您的 Client
final client = EasyDebugHttpClient(http.Client());
// 2. 像往常一样发起请求
final response = await client.get(Uri.parse('https://example.com'));

这就完事了!请求会自动出现在 EasyDebug 的 Network 列表中。✅


🎨 3. 界面交互大升级

为了容纳更多功能,我对 UI 进行了重构:

  • Tab 导航:顶部新增了 [Network][Logs][Settings] 三个 Tab,切换更加丝滑。
  • 斑马纹列表:日志列表加入了斑马纹背景,长列表阅读更舒适。

2.png

4.png


📦 如何升级?

在您的 

pubspec.yaml 中升级版本:

dependencies:
  easy_debug: ^0.0.4

欢迎大家更新体验!如果有 Bug 或建议,依然欢迎在评论区或 GitHub 提 Issue 哦!

GitHub

Pub

Author

Created by JasonBoolean.

❌
❌