普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月24日掘金 前端

请求 ID 跟踪模式:解决异步请求竞态条件

作者 鲫小鱼
2025年12月24日 18:36

📋 目录


问题背景

在搜索场景中,用户快速输入关键词时会触发多个并发请求:

// 用户快速输入:a → ac → acd
// 会触发 3 个请求,但返回顺序可能不同

问题表现:

  • 推荐商品列表有时会多出 5 个商品
  • 显示的商品与当前关键词不匹配
  • 旧请求的结果覆盖了新请求的结果

问题分析

竞态条件(Race Condition)

当多个异步请求并发执行时,由于网络延迟不同,返回顺序可能与发起顺序不一致:

时间线:
T1: 用户输入 "a"  → 触发请求1
T2: 用户输入 "ac" → 触发请求2
T3: 请求2返回     → 设置 productList = ["ac相关商品"]
T4: 请求1返回     → 设置 productList = ["a相关商品"] ❌ 错误!

根本原因:

  • React 的 setState 是异步的
  • 多个请求同时进行,无法保证哪个先返回
  • 旧请求的结果可能覆盖新请求的结果

解决方案

请求 ID 跟踪机制

使用一个全局递增的请求 ID 来跟踪每个请求,确保只处理最新请求的结果。

核心思路

  1. 每个请求分配唯一 ID:使用 useRef 保存一个递增的计数器
  2. 请求开始时保存 ID:在闭包中保存当前请求的 ID
  3. 请求返回时验证:比较保存的 ID 和最新的 ID,判断请求是否仍然有效

实现原理

1. 添加请求 ID 跟踪器

// 用于跟踪当前请求的 ID,确保只处理最新请求的结果
const requestIdRef = useRef<number>(0)

为什么使用 useRef

  • useRef 的值在组件重新渲染时保持不变
  • .current 属性是可变的,可以随时更新
  • 不会触发组件重新渲染

2. 请求开始时生成并保存 ID

useEffect(() => {
  const fetchProductList = async () => {
    // 生成新的请求 ID(先递增再取值)
    const currentRequestId = ++requestIdRef.current

    // 保存当前请求的关键词(双重验证)
    const currentKeyword = keyWord.trim()

    // ... 发起请求
  }

  fetchProductList()
}, [keyWord])

关键点:

  • ++requestIdRef.current 先递增再取值
  • currentRequestId 被闭包捕获,保存请求开始时的值
  • currentKeyword 也被闭包捕获,用于双重验证

3. 请求返回时验证 ID

const response = await getPublicSearchFilter(params)

// 检查是否是最新的请求
if (currentRequestId !== requestIdRef.current) {
  return  // 忽略旧请求的结果
}

// 双重验证:检查关键词是否仍然匹配
if (currentKeyword !== keyWord.trim()) {
  return  // 关键词已改变,忽略结果
}

// 只有通过所有检查才设置 state
setProductList([...proList])

完整代码示例

import { useState, useEffect, useRef } from 'react'

const SearchList = () => {
  const [productList, setProductList] = useState<any[]>([])
  const [productLoading, setProductLoading] = useState<boolean>(false)

  // 用于跟踪当前请求的 ID
  const requestIdRef = useRef<number>(0)

  // 获取推荐商品列表
  useEffect(() => {
    const fetchProductList = async () => {
      // 如果没有搜索关键字,不请求
      if (!keyWord || !keyWord.trim()) {
        setProductList([])
        return
      }

      // 生成新的请求 ID
      const currentRequestId = ++requestIdRef.current
      // 保存当前请求的关键词,用于验证结果是否仍然有效
      const currentKeyword = keyWord.trim()

      setProductLoading(true)
      try {
        const params: Search.SearchParams = {
          keyword: currentKeyword,
          size: 5,
          page: 1,
        }

 
        const response = await getPublicSearchFilter(params)

        // ✅ 检查1:是否是最新的请求
        // 如果不是则忽略结果,避免旧的请求结果覆盖新的结果
        if (currentRequestId !== requestIdRef.current) {
          return
        }

        // ✅ 检查2:关键词是否仍然匹配(双重保险)
        if (currentKeyword !== keyWord.trim()) {
          return
        }

        const items = response?.itemList?.data || []
        const proList = items.slice(0, 5)

        setProductList([...proList])
      } catch (error) {
        // 检查是否是最新的请求,如果不是则忽略错误
        if (currentRequestId !== requestIdRef.current) {
          return
        }
        console.error('获取推荐商品失败:', error)
        setProductList([])
      } finally {
        // 只有在是最新请求时才更新 loading 状态
        if (currentRequestId === requestIdRef.current) {
          setProductLoading(false)
        }
      }
    }

    fetchProductList()
  }, [keyWord])

  // ... 其他代码
}

闭包与 Ref 深入理解

关键概念

1. currentRequestId 被闭包捕获

const fetchProductList = async () => {
  // 这一行执行时,currentRequestId 被"冻结"在闭包中
  const currentRequestId = ++requestIdRef.current  // 假设此时 = 1

  // ... 发起异步请求 ...
  await getPublicSearchFilter(params)  // 这里等待,可能需要几秒钟

  // 当请求返回时,currentRequestId 仍然是 1(闭包保存的值)
  // 但 requestIdRef.current 可能已经是 2、3、4...(最新值)
  if (currentRequestId !== requestIdRef.current) {
    return
  }
}

要点:

  • currentRequestId 是局部常量,在函数执行时被赋值
  • 异步函数返回时,它仍然保持请求开始时的值
  • 这就是闭包:函数"记住"了创建时的变量值

2. requestIdRef.current 始终是最新的

// requestIdRef 是一个 ref 对象
const requestIdRef = useRef<number>(0)

// ref.current 是一个可变引用,每次读取都返回最新值
requestIdRef.current  // 读取时总是最新值

要点:

  • requestIdRef 是 React 的 ref 对象,.current 是可变的
  • 每次读取 requestIdRef.current 都会得到当前最新值
  • 不受闭包影响,因为它不是被捕获的变量,而是通过引用访问

时间线示例

// === 初始状态 ===
requestIdRef.current = 0

// === T1: 用户输入 "a",触发请求1 ===
const fetchProductList1 = async () => {
  const currentRequestId = ++requestIdRef.current
  // 执行后:currentRequestId = 1, requestIdRef.current = 1

  // 闭包捕获:currentRequestId = 1(被"冻结")
  // ref 引用:requestIdRef.current(随时可读取最新值)

  await getPublicSearchFilter(...)  // 等待响应...
}

// === T2: 用户输入 "ac",触发请求2(请求1还在等待中)===
const fetchProductList2 = async () => {
  const currentRequestId = ++requestIdRef.current
  // 执行后:currentRequestId = 2, requestIdRef.current = 2

  await getPublicSearchFilter(...)  // 等待响应...
}

// === T3: 请求1返回(此时 requestIdRef.current 已经是 2)===
// 在 fetchProductList1 的闭包中:
if (currentRequestId !== requestIdRef.current) {
  // currentRequestId = 1(闭包保存的旧值)
  // requestIdRef.current = 2(读取的最新值)
  // 1 !== 2 ✅ 返回,忽略结果
  return
}

// === T4: 请求2返回 ===
// 在 fetchProductList2 的闭包中:
if (currentRequestId !== requestIdRef.current) {
  // currentRequestId = 2(闭包保存的值)
  // requestIdRef.current = 2(如果此时没有新请求)
  // 2 === 2 ✅ 通过检查,设置 state
}

内存中的状态

// 内存布局示意:

// 全局 ref(所有函数共享)
requestIdRef = {
  current: 3  // ← 始终是最新值,随时可读取
}

// 请求1的闭包(已废弃)
fetchProductList1 闭包环境:
  currentRequestId: 1  // ← 被"冻结",不会改变

// 请求2的闭包(已废弃)
fetchProductList2 闭包环境:
  currentRequestId: 2  // ← 被"冻结",不会改变

// 请求3的闭包(当前有效)
fetchProductList3 闭包环境:
  currentRequestId: 3  // ← 被"冻结",不会改变

对比:闭包 vs Ref

特性 currentRequestId (闭包) requestIdRef.current (Ref)
值的变化 创建时赋值后不再改变 每次读取都是最新值
作用域 函数闭包内 全局可访问
用途 保存请求开始时的 ID 保存最新的请求 ID
类比 拍照(定格瞬间) 实时监控(动态更新)

为什么这样设计有效?

// 关键代码
const currentRequestId = ++requestIdRef.current  // 闭包捕获:保存"快照"
// ... 异步操作 ...
if (currentRequestId !== requestIdRef.current) {  // 比较"快照"和"实时值"
  return  // 如果不同,说明已有新请求
}

工作原理:

  1. 请求开始时currentRequestId 保存当前 ID(快照)
  2. 请求进行中requestIdRef.current 可能被新请求更新
  3. 请求返回时:比较快照和最新值
    • 相同 → 仍是最新请求,处理结果
    • 不同 → 已被新请求取代,忽略结果

最佳实践

1. 何时使用请求 ID 跟踪?

适用场景:

  • 用户输入触发的搜索请求
  • 下拉选择触发的数据加载
  • 任何可能快速连续触发的异步操作

不适用场景:

  • 一次性请求(如页面初始化)
  • 按钮点击触发的请求(用户不会快速点击)
  • 定时轮询请求(通常需要取消机制)

2. 双重验证的必要性

// 检查1:请求 ID(主要检查)
if (currentRequestId !== requestIdRef.current) {
  return
}

// 检查2:关键词匹配(双重保险)
if (currentKeyword !== keyWord.trim()) {
  return
}

为什么需要双重验证?

  • 请求 ID 检查:防止旧请求覆盖新请求
  • 关键词检查:防止边界情况(如请求 ID 相同但关键词已改变)

3. 错误处理

catch (error) {
  // 检查是否是最新的请求,如果不是则忽略错误
  if (currentRequestId !== requestIdRef.current) {
    return
  }
  console.error('获取推荐商品失败:', error)
  setProductList([])
}

要点:

  • 错误处理也要检查请求 ID
  • 避免旧请求的错误影响新请求的状态

4. Loading 状态管理

finally {
  // 只有在是最新请求时才更新 loading 状态
  if (currentRequestId === requestIdRef.current) {
    setProductLoading(false)
  }
}

要点:

  • Loading 状态也要检查请求 ID
  • 避免旧请求的 loading 状态影响 UI

其他解决方案对比

方案1:AbortController(推荐用于可取消的请求)

const abortControllerRef = useRef<AbortController | null>(null)

useEffect(() => {
  // 取消之前的请求
  if (abortControllerRef.current) {
    abortControllerRef.current.abort()
  }

  const abortController = new AbortController()
  abortControllerRef.current = abortController

  fetch(url, { signal: abortController.signal })
    .then(response => {
      if (abortController.signal.aborted) return
      // 处理响应
    })
}, [deps])

优点:

  • 可以真正取消网络请求
  • 节省带宽和服务器资源

缺点:

  • 需要 API 支持 AbortController
  • 某些旧的 API 可能不支持

方案2:请求 ID 跟踪(本文方案)

优点:

  • 适用于任何异步操作
  • 不依赖 API 支持
  • 实现简单

缺点:

  • 不能真正取消网络请求
  • 请求仍会占用带宽

方案3:防抖(Debounce)

const debouncedSearch = useMemo(
  () => debounce((keyword: string) => {
    fetchProductList(keyword)
  }, 300),
  []
)

优点:

  • 减少请求次数
  • 简单易用

缺点:

  • 延迟响应
  • 用户可能等待更长时间

总结

核心要点

  1. 问题根源:多个异步请求并发执行,返回顺序不确定
  2. 解决方案:使用请求 ID 跟踪,确保只处理最新请求
  3. 关键机制:闭包保存"快照",Ref 提供"实时值"
  4. 验证策略:双重验证(请求 ID + 业务参数)

适用场景

✅ 搜索输入框的联想词/推荐商品 ✅ 下拉选择的数据加载 ✅ 快速连续触发的异步操作

关键代码模式

// 1. 创建跟踪器
const requestIdRef = useRef<number>(0)

// 2. 请求开始时保存 ID
const currentRequestId = ++requestIdRef.current

// 3. 请求返回时验证
if (currentRequestId !== requestIdRef.current) {
  return  // 忽略旧请求
}

记忆口诀

"闭包保存快照,Ref 提供实时值,比较两者判断有效性"

最后感谢阅读!欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!!!

SSE与流式传输(Streamable HTTP)

2025年12月24日 18:10

SSE被冷落了十多年,终于随AI火了一把。过去大家宁可用websocket也不愿意使用SSE,以至于AI出来后,很多人认为这是个新技术。实际上它很久以前就是HTML5标准中的一部分了。

MCP兴起后,有些人认为SSE与Streamable HTTP是两个概念,其实不然。本文将理清SSE和Streamalbe HTTP两者的概念与关系,并给出实践中的一些小建议。

SSE

SSE是Streamable HTTP的一个实现:SSE不仅对请求头有要求——必须设置Content-Type: text/event-stream,而且对传输格式、数据解析做了详细约定:

image.png

除此之外还有自动重连机制,具体可见HTML5标准 html.spec.whatwg.org/multipage/s…

除了这些具体的规范外,SSE只能发送get请求,并且只能由客户端主动关闭。另外,从"text/event-stream"上可以看出,SSE聚焦于文本内容传输,要传二进制内容就比较麻烦。

总的来说,SSE是由HTML5标准规定,针对前端场景特殊规定的流式传输协议。基于SSE的流式传输,可以通过EventSource对象实现,也可以通过fetch自行处理请求/解析/重连。

Streamable HTTP

Streamable HTTP虽然与SSE一样依赖http协议中的keep-alive,但更底层和中立。

它的核心是Transfer-Encoding: chunked(http1.1),此外没有其他约束。

如果使用http2,可以不声明Transfer-Encoding,只要持续写就行了,因为http2能自动分帧。

当服务器返回的响应中缺少Content-Length头部,且连接保持开放(Connection: keep-alive)时,HTTP/1.1 协议会默认使用Transfer-Encoding: chunked编码来传输响应数据,SSE刚好满足这两个条件,因此也是chunked transfer传输的。

从这个角度来说,SSE就是Streamable HTTP传输的一个实现——SSE = Streamable HTTP + 事件编码规范

由于Streamable HTTP并没有规定数据格式和解析方法,因此使用者可以根据场景自行协商:

SSE传输:
data: {"token":"Hello"}
data: {"token":"world"}
data: [DONE]

Streamable HTTP传输:
{"type":"token","content":"Hello"}
{"type":"token","content":"world"}
{"type":"done"}

从内容上可以看出,SSE必须解析data:开头,而Streamable可传输json string line等多种格式。

为什么MCP更青睐Streamable HTTP

原因 说明
🌐 跨语言兼容 SSE 原生仅限浏览器;Streamable HTTP 适配 SDK、CLI、服务端
🧱 结构灵活 支持 NDJSON、JSON Lines、Protocol Buffers
⚙️ 更贴近底层 I/O 方便控制 chunk 大小、流速、关闭机制
🧩 多类型输出 AI 不止发文本,还要发图像、语音、函数调用等
📦 工具链统一 与现代 fetch/Response API 一致

对ai应用来说,SSE过于死板。它规定了传输格式,编码方式,无法以二进制传输。在非浏览器环境中,使用更原始的Streamable HTTP显然更合适。

流式传输的实践建议

  1. 如果没有二进制传输需求,可以使用SSE协议,服务端第三方开源工具也较多
  2. 浏览器端建议使用fetch而不是EventSource,便于传参和认证
  3. 浏览器端使用AbortController取消流式传输
  4. 服务端根据请求头的 Accept: 'text/event-stream' 判断是否以SSE格式传输(如果需要同时支持流式传输和普通分页传输)

Flutter 实战:基于 GetX + Obx 的企业级架构设计指南

作者 全栈派森
2025年12月24日 17:56

大家好,我是Petter Guo

一位热爱探索全栈工程师。在这里,我将分享个人Technical essentials,带你玩转前端后端DevOps 的硬核技术,解锁AI,助你打通技术任督二脉,成为真正的全能玩家!!

如果对你有帮助, 请点赞+ 收藏 +关注鼓励下, 学习公众号为 全栈派森

在 Flutter 开发中,状态管理一直是绕不开的话题。从 Provider 到 BLoC,再到 Riverpod,选择很多。但在追求开发效率运行性能平衡的场景下,GetX 无疑是目前的“版本之子”。

今天我们不谈简单的计数器 Demo,而是深入探讨:如何在企业级项目中,利用 GetX + Obx 构建一套高内聚、低耦合、易扩展的架构。

🎯 为什么选择 GetX + Obx?

在传统的 setStateChangeNotifier 模式中,我们常常面临全页重绘的问题。而 GetX 的 Obx 带来了细粒度的响应式编程

  1. 极简代码:无需 context,无需繁琐的模板代码。
  2. 精准刷新:变量变了,只有使用该变量的 Widget 会刷新,性能极高。
  3. 依赖注入:自带强大的 DI(依赖注入)系统,彻底解耦 Logic 和 View。

🏗️ 目录架构设计 (The Architecture)

对于中大型项目,推荐使用 Feature-First(按功能分包) 的目录结构,结合 GetX Pattern 标准:

lib/
├── app/
│   ├── data/                   # 数据层 (全局共享)
│   │   ├── models/             # 实体类 (Json转Dart)
│   │   ├── providers/          # API 请求封装 (Dio/GetConnect)
│   │   └── services/           # 全局服务 (本地存储/Auth服务)
│   │
│   ├── modules/                # 业务模块 (核心)
│   │   ├── home/               # 首页模块
│   │   │   ├── bindings/       # 依赖注入 (Binding)
│   │   │   ├── controllers/    # 业务逻辑 (Controller)
│   │   │   └── views/          # 页面视图 (View)
│   │   │
│   │   ├── profile/            # 个人中心模块
│   │   │   ├── ...
│   │
│   ├── routes/                 # 路由管理
│   │   ├── app_pages.dart      # 路由表
│   │   └── app_routes.dart     # 路由名称常量
│   │
│   └── utils/                  # 工具类
│
└── main.dart                   # 入口文件

设计核心: 每个业务模块(Module)自包含 ViewControllerBinding,互不干扰。


💻 代码实战 (Code Implementation)

我们要实现一个场景:用户详情页。进入页面自动请求 API,加载中显示 Loading,成功显示数据,失败显示重试按钮。

1. Model 层:定义数据

app/data/models/user_model.dart

class User {
  final String id;
  final String name;
  final String avatar;

  User({required this.id, required this.name, required this.avatar});

  // 实际开发中建议使用 json_serializable
  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: json['id'] ?? '',
      name: json['name'] ?? 'Unknown',
      avatar: json['avatar'] ?? '',
    );
  }
}

2. Provider 层:API 请求

app/data/providers/user_provider.dart

这里负责纯粹的数据获取,不含业务逻辑。

import 'package:get/get.dart';

class UserProvider extends GetConnect {
  Future<Response> getUser(String id) => get('https://api.example.com/users/$id');
}

3. Controller 层:核心逻辑 (关键!)

app/modules/profile/controllers/profile_controller.dart

这是 GetX 的灵魂所在。我们使用 .obs 将变量变为响应式。

import 'package:get/get.dart';
import '../../../data/models/user_model.dart';
import '../../../data/providers/user_provider.dart';

// 状态枚举
enum Status { loading, success, error }

class ProfileController extends GetxController {
  final UserProvider _api;
  
  // 构造注入,便于测试
  ProfileController(this._api);

  // --- 响应式状态 (State) ---
  
  // 使用 Rx<T> 包装对象
  final user = Rxn<User>(); 
  // 页面状态
  final status = Status.loading.obs;

  @override
  void onInit() {
    super.onInit();
    fetchUserData(); // 初始化时加载数据
  }

  // --- 业务方法 (Action) ---
  
  void fetchUserData() async {
    status.value = Status.loading;
    try {
      // 模拟网络延迟
      await Future.delayed(const Duration(seconds: 1));
      
      final response = await _api.getUser('123');
      
      // 这里的逻辑通常更复杂,需判断 statusCode
      if (response.hasError) {
         status.value = Status.error;
      } else {
         user.value = User.fromJson(response.body);
         status.value = Status.success;
      }
    } catch (e) {
      status.value = Status.error;
    }
  }
}

4. Binding 层:依赖注入胶水

app/modules/profile/bindings/profile_binding.dart

Binding 的作用是:“只有当用户进入这个页面时,才创建 Controller 和 Provider;离开页面时自动销毁。”

import 'package:get/get.dart';
import '../controllers/profile_controller.dart';
import '../../../data/providers/user_provider.dart';

class ProfileBinding extends Bindings {
  @override
  void dependencies() {
    // 1. 注入数据提供者
    Get.lazyPut(() => UserProvider());
    
    // 2. 注入控制器 (Controller 能找到上面的 UserProvider)
    Get.lazyPut(() => ProfileController(Get.find()));
  }
}

5. View 层:UI 视图

app/modules/profile/views/profile_view.dart

View 层变得非常干净,没有逻辑,只有布局。Obx 是这里的魔法

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/profile_controller.dart';

// 继承 GetView<T> 可以直接访问 controller 属性
class ProfileView extends GetView<ProfileController> {
  const ProfileView({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("用户详情")),
      body: Center(
        // Obx 监听:只要 controller.status 变化,这里就会重绘
        child: Obx(() {
          switch (controller.status.value) {
            case Status.loading:
              return const CircularProgressIndicator();
            
            case Status.error:
              return Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  const Icon(Icons.error, color: Colors.red, size: 50),
                  const SizedBox(height: 10),
                  ElevatedButton(
                    onPressed: controller.fetchUserData,
                    child: const Text("重试"),
                  )
                ],
              );
              
            case Status.success:
              final userData = controller.user.value;
              return Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  CircleAvatar(radius: 40, backgroundImage: NetworkImage(userData!.avatar)),
                  const SizedBox(height: 10),
                  Text(userData.name, style: Theme.of(context).textTheme.headlineMedium),
                  ElevatedButton(
                    // 交互逻辑都在 Controller 里
                    onPressed: () => Get.snackbar("提示", "点击了编辑"),
                    child: const Text("编辑资料"),
                  )
                ],
              );
          }
        }),
      ),
    );
  }
}

6. Route 层:组装

app/routes/app_pages.dart

class AppPages {
  static final routes = [
    GetPage(
      name: '/profile',
      page: () => const ProfileView(),
      binding: ProfileBinding(), // 关键:在这里绑定依赖
    ),
  ];
}

🌟 总结:这套架构好在哪?

  1. 内存管理自动化: 由于使用了 BindingGet.lazyPut,当用户从 Profile 页返回上一页时,ProfileControllerUserProvider 会自动从内存中移除。你不需要手动写 dispose()
  2. View 层极度纯净: UI 代码中没有 if(isLoading) ... else ... 的业务判断逻辑,也没有 API 请求代码。UI 只负责“根据状态显示组件”。
  3. 测试友好: 因为 Controller 的依赖(Provider)是通过构造函数注入的,写单元测试时,你可以轻松 mock 一个 UserProvider 传进去。

写在最后:架构没有绝对的“最好”,只有“最适合”。对于追求开发速度和运行效率的中小型及企业级 Flutter 项目,GetX + Obx 是一套性价比极高的组合拳。希望这篇实战指南能对你的项目架构有所启发!

Node.js的package.json

2025年12月24日 17:48

package.jsonNode.js 和前端项目的核心配置文件,它是一个 JSON 格式 的文件,用来描述项目的元数据、依赖、脚本等信息。

下面我给你一个 完整示例详细解析,方便你快速掌握。


1. 基本作用

  • 项目描述(名称、版本、作者等)
  • 依赖管理(生产依赖、开发依赖)
  • 脚本命令npm run xxx
  • 工具配置(如 ESLint、Babel、TypeScript 等)

2. 示例 package.json

Json
{
  "name": "my-node-app",                // 项目名称(必须小写、无空格)
  "version": "1.0.0",                   // 版本号(遵循 semver 语义化版本)
  "description": "A sample Node.js app",// 项目描述
  "main": "index.js",                   // 入口文件
  "type": "module",                     // 模块类型: "commonjs""module" (ESM)
  "scripts": {                          // npm 脚本命令
    "start": "node index.js",
    "dev": "nodemon index.js",
    "test": "node test.js"
  },
  "keywords": ["node", "example"],      // 关键词(方便 npm 搜索)
  "author": "Your Name",                // 作者
  "license": "MIT",                     // 许可证
  "dependencies": {                     // 生产依赖
    "express": "^4.18.2"
  },
  "devDependencies": {                  // 开发依赖
    "nodemon": "^3.0.1"
  },
  "engines": {                           // Node 版本要求
    "node": ">=18.0.0"
  }
}

3. 常用字段说明

字段 作用
name 包名(npm 发布时使用)
version 版本号(语义化版本:主.次.补丁)
description 项目描述
main 入口文件(require() 默认加载)
type 模块类型(commonjsmodule
scripts 自定义命令(npm run xxx
dependencies 生产环境依赖
devDependencies 开发环境依赖
peerDependencies 对等依赖(插件/库常用)
engines 指定 Node/npm 版本
license 开源协议

4. 常用命令

Bash
# 初始化 package.json
npm init -y

# 安装生产依赖
npm install express

# 安装开发依赖
npm install nodemon --save-dev

# 运行脚本
npm run start
npm run dev

5. 版本号规则(SemVer)

  • ^1.2.3:允许 次版本补丁版本 更新(1.x.x
  • ~1.2.3:允许 补丁版本 更新(1.2.x
  • 1.2.3:固定版本
  • *:任意版本(不推荐)

Vue3自定义渲染器:原理剖析与实践指南

2025年12月24日 17:42

Vue3的自定义渲染器是框架架构中的一项革命性特性,它打破了Vue只能用于DOM渲染的限制,让开发者能够将Vue组件渲染到任意目标平台。本文将深入探讨Vue3自定义渲染器的核心原理,并通过TresJS这个优秀的3D渲染库来展示其实际应用。

什么是自定义渲染器

在传统的Vue应用中,渲染器负责将Vue组件转换为DOM元素。而Vue3引入的自定义渲染器API允许我们创建专门的渲染器,将Vue组件转换为任意类型的目标对象。TresJS正是利用这一特性,将Vue组件转换为Three.js的3D对象,让开发者能够使用声明式的Vue语法来构建3D场景。

传统DOM渲染器 vs 自定义渲染器

传统DOM渲染器的操作流程非常直观:

// Vue DOM渲染器操作
const div = document.createElement('div')  // 创建元素
div.textContent = 'Hello World'           // 设置属性
document.body.appendChild(div)            // 挂载到父元素
div.style.color = 'red'                   // 更新属性
document.body.removeChild(div)            // 卸载元素

而TresJS的自定义渲染器执行类似的操作,但目标对象是Three.js对象:

// TresJS渲染器操作
const mesh = new THREE.Mesh()                    // 创建Three.js对象
mesh.material = new THREE.MeshBasicMaterial()    // 设置属性
scene.add(mesh)                                  // 添加到场景
mesh.position.set(1, 2, 3)                       // 更新属性
scene.remove(mesh)                               // 从场景移除

自定义渲染器API核心

TresJS的自定义渲染器(nodeOps)实现了一套操作接口,当Vue需要执行以下操作时会调用这些接口:

  • 创建新的Three.js对象
  • 将对象添加到场景或其他对象中
  • 更新对象属性
  • 从场景中移除对象

这种架构设计让Vue的组件系统与具体的渲染目标解耦,使得同一个组件模型可以驱动不同的渲染后端。

响应式系统在3D渲染中的挑战

Vue的响应式系统虽然强大,但在3D场景中需要谨慎使用。在60FPS的渲染循环中,不当的响应式使用会导致严重的性能问题。

性能挑战

Vue的响应式基于JavaScript Proxy,每次属性访问和修改都会被拦截。在3D渲染循环中,这意味着每秒60次触发响应式系统:

// ❌ 这种做法会导致性能问题
const position = reactive({ x: 0, y: 0, z: 0 })

const { onBeforeRender } = useLoop()
onBeforeRender(() => {
  // 每秒触发Vue响应式系统60次
  position.x = Math.sin(Date.now() * 0.001) * 3
  position.y = Math.cos(Date.now() * 0.001) * 2
})

性能对比数据令人警醒:普通对象的属性访问可达每秒5000万次,而响应式对象由于代理开销只能达到每秒200万次。

解决方案:模板引用的艺术

模板引用(Template Refs)提供了直接访问Three.js实例的能力,避免了响应式开销,是动画和频繁更新的最佳选择:

// ✅ 推荐做法:使用模板引用
const meshRef = shallowRef(null)

const { onBeforeRender } = useLoop()
onBeforeRender(({ elapsed }) => {
  if (meshRef.value) {
    // 直接属性修改,无响应式开销
    meshRef.value.rotation.x = elapsed * 0.5
    meshRef.value.rotation.y = elapsed * 0.3
    meshRef.value.position.y = Math.sin(elapsed) * 2
  }
})
<template>
  <TresCanvas>
    <TresPerspectiveCamera :position="[0, 0, 5]" />
    <TresAmbientLight />
    
    <!-- 模板引用连接到Three.js实例 -->
    <TresMesh ref="meshRef">
      <TresBoxGeometry />
      <TresMeshStandardMaterial color="#ff6b35" />
    </TresMesh>
  </TresCanvas>
</template>

浅层响应式:平衡的艺术

当需要部分响应式时,shallowRefshallowReactive提供了完美的平衡:

// ✅ 只让顶层属性具有响应性
const meshProps = shallowReactive({
  color: '#ff6b35',
  wireframe: false,
  visible: true,
  position: { x: 0, y: 0, z: 0 }  // 这个对象不是深度响应式的
})

// UI控制修改外观
const toggleWireframe = () => {
  meshProps.wireframe = !meshProps.wireframe  // 响应式更新
}

const { onBeforeRender } = useLoop()
onBeforeRender(() => {
  if (meshRef.value) {
    // 直接位置修改,无响应式开销
    meshRef.value.position.y = Math.sin(Date.now() * 0.001) * 2
  }
})

最佳实践模式

1. 初始定位与动画分离

使用响应式属性进行初始定位,使用模板引用进行动画:

// ✅ 响应式初始状态
const initialPosition = ref([0, 0, 0])
const color = ref('#ff6b35')

// ✅ 模板引用用于动画
const meshRef = shallowRef(null)

const { onBeforeRender } = useLoop()
onBeforeRender(({ elapsed }) => {
  if (meshRef.value) {
    // 相对于初始位置进行动画
    meshRef.value.position.y = initialPosition.value[1] + Math.sin(elapsed) * 2
  }
})

2. 计算属性优化复杂计算

对于不应在每帧运行的昂贵计算,使用计算属性:

// ✅ 计算属性只在依赖改变时重新计算
const orbitPositions = computed(() => {
  const positions = []
  for (let i = 0; i < settings.objects; i++) {
    const angle = (i / settings.objects) * Math.PI * 2
    positions.push({
      x: Math.cos(angle) * settings.radius,
      z: Math.sin(angle) * settings.radius
    })
  }
  return positions
})

3. 基于生命周期的更新

使用Vue的生命周期钩子处理性能敏感的更新:

const animationState = {
  time: 0,
  amplitude: 2,
  frequency: 1
}

const { onBeforeRender } = useLoop()
onBeforeRender(({ delta }) => {
  if (!isAnimating.value || !meshRef.value) return
  
  // 更新非响应式状态
  animationState.time += delta
  
  // 应用到Three.js实例
  meshRef.value.position.y = Math.sin(animationState.time * animationState.frequency) * animationState.amplitude
})

常见陷阱与规避

陷阱1:在动画中使用响应式数据

// ❌ 避免:在渲染循环中使用响应式对象
const rotation = reactive({ x: 0, y: 0, z: 0 })

onBeforeRender(({ elapsed }) => {
  rotation.x = elapsed * 0.5  // 每帧触发Vue响应式系统
  rotation.y = elapsed * 0.3
})

解决方案:使用模板引用

// ✅ 推荐:直接实例操作
const meshRef = shallowRef(null)

onBeforeRender(({ elapsed }) => {
  if (meshRef.value) {
    meshRef.value.rotation.x = elapsed * 0.5
    meshRef.value.rotation.y = elapsed * 0.3
  }
})

陷阱2:深度响应式数组

// ❌ 避免:深度响应式数组更新
const particles = reactive(Array.from({ length: 100 }, (_, i) => ({
  position: { x: i, y: 0, z: 0 },
  velocity: { x: 0, y: 0, z: 0 }
})))

onBeforeRender(() => {
  particles.forEach((particle) => {
    // 100个响应式对象的开销极大
    particle.position.x += particle.velocity.x
  })
})

解决方案:非响应式数据+模板引用

// ✅ 推荐:普通对象+模板引用
const particleData = Array.from({ length: 100 }, (_, i) => ({
  position: { x: i, y: 0, z: 0 },
  velocity: { x: (Math.random() - 0.5) * 0.1, y: 0, z: 0 }
}))

const particleRefs = shallowRef([])

onBeforeRender(() => {
  particleData.forEach((particle, index) => {
    // 更新普通对象数据
    particle.position.x += particle.velocity.x
    
    // 应用到Three.js实例
    const mesh = particleRefs.value[index]
    if (mesh) {
      mesh.position.set(particle.position.x, particle.position.y, particle.position.z)
    }
  })
})

性能监控与优化

使用性能监控工具如@tresjs/leches来实时监控FPS:

import { TresLeches, useControls } from '@tresjs/leches'

// 启用FPS监控
useControls('fpsgraph')

核心要点总结

Vue3自定义渲染器为跨平台渲染开辟了全新的可能性,但在3D渲染这样的高性能场景中,需要明智地选择响应式策略:

  1. 模板引用优先:在渲染循环中使用模板引用直接操作Three.js实例,避免响应式开销
  2. 浅层响应式:当需要部分响应式时,使用shallowRefshallowReactive获得平衡
  3. 关注点分离:保持UI状态的响应性和动画状态的非响应性,以获得最佳性能
  4. 持续监控:使用性能监控工具识别3D场景中的响应式瓶颈

通过理解并应用这些模式,开发者可以创建既具有Vue开发体验优势,又能在高性能3D环境中流畅运行的应用。Vue3自定义渲染器不仅是一个技术特性,更是连接声明式编程与多样化渲染目标的桥梁,为前端开发开启了全新的创作空间。

🚀 效率暴增!Vue.js开发必知的15个神级提效工具

作者 Mr_chiu
2025年12月24日 17:34

告别996,从善用工具开始

开篇:那些年我们浪费的时间

作为一名有五年经验的Vue工程师,我曾无数次在重复劳动中挣扎:手动复制相似的组件代码、在浏览器和编辑器之间反复切换调试、为项目初始化配置花费半天时间……直到我开始系统性地收集和整合提效工具,我的开发效率才有了质的飞跃。

今天,我分享的这些工具,是我从无数工具中筛选出的精华,每个都在我的日常Vue开发工作中扮演着重要角色。

一、脚手架与项目启动工具

1. Plop.js + Vue模板 - 代码生成器中的瑞士军刀

Plop.js 是我Vue项目中的标配。它基于模板快速生成组件、页面或模块,确保团队代码一致性。

// plopfile.js 配置示例 - Vue版本
module.exports = function (plop) {
  plop.setGenerator('component', {
    description: '创建一个Vue组件',
    prompts: [{
      type: 'input',
      name: 'name',
      message: '组件名称(使用大驼峰命名):'
    }],
    actions: [{
      type: 'add',
      path: 'src/components/{{properCase name}}/{{properCase name}}.vue',
      templateFile: 'templates/component.hbs'
    }, {
      type: 'add',
      path: 'src/components/{{properCase name}}/index.js',
      templateFile: 'templates/index.hbs'
    }]
  });
};

// component.hbs 模板示例
<template>
  <div class="{{kebabCase name}}">
    <!-- 组件内容 -->
  </div>
</template>

<script setup>
defineProps({
  // props定义
})
</script>

<style scoped>
.{{kebabCase name}} {
  /* 样式 */
}
</style>

技术要点:Plop使用Handlebars模板引擎,结合Inquirer.js实现交互式生成。我们可以为不同类型的Vue组件(如组件、页面、组合式函数)预设不同的模板。

2. Vite + Vue 3 - 新一代Vue构建体验

Vite的闪电般的热更新速度,让Vue开发体验达到了新高度:

# 创建Vue项目
npm create vue@latest my-vue-app

# 选择需要的特性
# √ 是否使用 TypeScript? Yes
# √ 是否启用 JSX 支持? Yes  
# √ 是否添加 Vue Router? Yes
# √ 是否添加 Pinia? Yes
# √ 是否添加 Vitest? Yes

# 开发模式启动(毫秒级)
npm run dev

深度优化技巧:通过配置vite.config.js中的vue插件选项,开启响应式语法糖和性能优化:

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    vue({
      reactivityTransform: true, // 启用响应式语法糖
      template: {
        compilerOptions: {
          // 自定义编译器选项
        }
      }
    })
  ]
})

二、开发与调试利器

3. Vue.js devtools 6.0 - Vue开发者的超级武器

Vue.js devtools是Vue开发者不可或缺的调试工具,新版支持Vue 3和组合式API:

核心功能

  • 组件树:可视化查看组件层级和状态
  • 时间旅行:跟踪状态变化并回退到任意状态
  • 组合式函数调试:监控refreactivecomputed等响应式数据
  • 路由调试:Vue Router的状态和导航历史
  • Pinia集成:直接查看和修改Pinia store状态

高级技巧

  • 使用"Open in editor"功能快速定位组件源码
  • 通过时间轴功能分析组件更新性能
  • 自定义面板扩展,如VueUse状态监控

4. Volar + TypeScript - 智能Vue开发体验

Volar是Vue 3官方推荐的VSCode插件,提供极致的TypeScript支持:

// 完整的TypeScript智能提示
<script setup lang="ts">
import { ref } from 'vue'

// 类型推断和自动补全
const count = ref(0) // 类型为Ref<number>

// Props类型检查
defineProps<{
  title: string
  value?: number
}>()

// 事件类型定义
const emit = defineEmits<{
  (e: 'update', value: number): void
  (e: 'change', value: string): void
}>()
</script>

配置优化

// .vscode/settings.json
{
  "volar.tsPlugin": true,
  "volar.completion.preferredTagNameCase": "pascal",
  "volar.autoCompleteRefs": true,
  "volar.codeLens.scriptSetupTools": true
}

三、代码质量与自动化

5. Vue ESLint插件 + lint-staged - Vue代码质量卫士

针对Vue的专门化代码检查:

// .eslintrc.js
module.exports = {
  extends: [
    'plugin:vue/vue3-recommended',
    '@vue/typescript/recommended'
  ],
  rules: {
    'vue/multi-word-component-names': 'off',
    'vue/no-v-html': 'warn',
    'vue/component-tags-order': ['error', {
      order: ['script', 'template', 'style']
    }]
  }
}

// 结合Husky和lint-staged
{
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged",
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  },
  "lint-staged": {
    "*.{vue,js,jsx,ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ]
  }
}

6. Vue Test Utils + Vitest - 现代化Vue测试方案

// Component.test.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import Counter from './Counter.vue'

describe('Counter.vue', () => {
  it('renders correctly', () => {
    const wrapper = mount(Counter, {
      props: { initialValue: 5 }
    })
    
    expect(wrapper.text()).toContain('5')
    expect(wrapper.find('button').exists()).toBe(true)
  })
  
  it('increments count on button click', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('button').trigger('click')
    expect(wrapper.text()).toContain('1')
  })
})

四、API与数据模拟

7. Mirage JS - Vue应用的全功能API模拟

// 在Vue应用中配置Mirage
import { createServer } from 'miragejs'

if (import.meta.env.DEV) {
  createServer({
    routes() {
      this.namespace = 'api'
      
      this.get('/users', () => {
        return [
          { id: 1, name: '用户1' },
          { id: 2, name: '用户2' }
        ]
      })
      
      this.post('/users', (schema, request) => {
        const attrs = JSON.parse(request.requestBody)
        return { id: 3, ...attrs }
      })
    }
  })
}

五、视觉与设计协作

8. Vue Figma插件 + Vue Design System

对于设计稿转Vue代码的完整方案:

// 使用Vue Design System
import { defineComponent, h } from 'vue'
import { 
  VButton, 
  VInput, 
  VCard,
  tokens 
} from 'vue-design-system'

// 从Figma插件导出的设计Token
const figmaTokens = {
  colors: {
    primary: '#007AFF',
    secondary: '#5856D6'
  },
  spacing: {
    xs: '4px',
    sm: '8px',
    md: '16px'
  }
}

// 自动生成Vue组件配置
export const designSystem = {
  components: {
    VButton,
    VInput,
    VCard
  },
  tokens: { ...tokens, ...figmaTokens }
}

六、文档与组件管理

9. VitePress + Vue Demoblock - 组件文档自动化

基于VitePress的Vue组件文档方案:

---
title: Button组件
---

# Button

通用的按钮组件

## 基础用法

:::demo
```vue
<template>
  <v-button type="primary">主要按钮</v-button>
</template>

:::

API

```

自动化脚本:自动从Vue组件中提取Props、Events、Slots信息生成API文档。

七、终端与工作流优化

10. Vue CLI插件开发 - 自定义项目生成器

创建自己的Vue项目模板和生成器:

// generator.js - 自定义Vue CLI插件
module.exports = (api, options) => {
  api.render('./template')
  
  api.extendPackage({
    dependencies: {
      'pinia': '^2.0.0',
      'vue-router': '^4.0.0'
    },
    scripts: {
      'analyze': 'vue-cli-service build --report'
    }
  })
  
  api.injectImports(api.entryFile, `import router from './router'`)
  api.afterInvoke(() => {
    // 生成后处理逻辑
  })
}

11. VueUse - 组合式函数工具集

// 使用VueUse提升开发效率
import { 
  useMouse, 
  useLocalStorage, 
  useFetch,
  useDebounceFn
} from '@vueuse/core'

// 在组合式函数中使用
export function useUserData() {
  // 自动持久化的状态
  const user = useLocalStorage('user', null)
  
  // 防抖请求
  const { data, error } = useFetch('/api/user')
    .debounced(300)
  
  return { user, data, error }
}

八、浏览器扩展宝藏

12. Vue.js devtools 6.0(浏览器扩展)

前面提到的Vue devtools的浏览器扩展版本,支持Chrome、Firefox、Edge等主流浏览器。

13. Vue Meta调试器 - SEO和管理工具

对于使用Vue Meta的应用,这款扩展可以实时查看和修改页面元信息。

九、高级代码搜索与导航

14. Vue Language Tools + TypeScript - 智能代码导航

// tsconfig.json 优化配置
{
  "vueCompilerOptions": {
    "target": 3,
    "plugins": [
      "@vue/language-plugin-pug"
    ],
    "experimentalRuntimeMode": "runtime-agnostic",
    "experimentalTemplateCompilerOptions": {
      "isCustomElement": tag => tag.startsWith('x-')
    }
  }
}

15. StackBlitz Vue项目 - 云端Vue开发环境

直接在浏览器中运行的完整Vue开发环境:

  • 支持Vite + Vue 3 + TypeScript
  • GitHub实时同步
  • 实时协作功能
  • 自定义模板分享

十、性能分析与优化

16. Vue Performance Devtool - 组件性能分析

专门针对Vue应用的性能分析工具:

// 安装和使用
import { createApp } from 'vue'
import { createPerformanceMonitor } from 'vue-performance-devtool'

const app = createApp(App)

if (import.meta.env.DEV) {
  const monitor = createPerformanceMonitor(app, {
    maxComponents: 50,
    trackHooks: true
  })
  monitor.start()
}

十一、状态管理增强

17. Pinia + Pinia Plugin - 现代化状态管理

// 使用Pinia插件增强开发体验
import { createPinia } from 'pinia'
import { createPersistedState } from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(createPersistedState({
  storage: localStorage,
  serializer: {
    serialize: JSON.stringify,
    deserialize: JSON.parse
  }
}))

// 自动生成TypeScript类型
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    name: '',
    email: ''
  }),
  getters: {
    displayName: (state) => state.name || '匿名用户'
  },
  actions: {
    async fetchUser() {
      // 异步操作
    }
  }
})

我的Vue工具化开发哲学

工具选择的核心原则:

  1. Vue生态优先 - 优先选择专门为Vue优化的工具
  2. 组合式API兼容 - 确保工具支持Vue 3组合式API
  3. TypeScript友好 - 完整的类型支持是必备条件
  4. 开发体验至上 - 热更新速度、智能提示质量是关键

实战:搭建Vue提效系统

我建议的逐步实施计划:

第一周:配置Volar + TypeScript + Vue ESLint,建立开发基础
第二周:集成Pinia + Vue Router,搭建状态管理和路由
第三周:配置Vitest + Vue Test Utils,建立测试体系
第四周:引入VitePress + 组件自动化文档
第五周:根据团队需求定制代码生成器和工作流

结语:Vue工具链的进化

从Vue 2到Vue 3,Vue的工具生态经历了革命性的变化。现代Vue开发不再是简单的模板编写,而是一个完整的工程化体系。掌握这些工具,不仅能提升开发效率,更能深入理解Vue的设计哲学和最佳实践。

真正的高级Vue工程师,不仅要会写组件,更要懂得如何利用工具链构建可维护、高性能、团队友好的Vue应用。


互动话题:你在Vue开发中有哪些私藏的提效工具?欢迎在评论区分享交流!如果这篇文章对你有帮助,请点赞收藏,后续我会分享更多Vue 3工程化实践和性能优化技巧。

从零搭建一个 React Todo 应用:父子通信、状态管理与本地持久化

作者 玉宇夕落
2025年12月24日 17:30
text
编辑
src/
├── App.jsx
├── components/
│   ├── TodoInput.jsx    // 添加任务输入框
│   ├── TodoList.jsx     // 任务列表展示
│   └── TodoStats.jsx    // 统计信息与清除已完成
└── styles/
    └── app.styl         // 全局样式(使用 Stylus)

整个应用围绕 “父组件持有状态,子组件通过 props 接收数据和回调” 的单向数据流原则构建。


📦 父组件:App.jsx —— 状态管理中心

jsx
编辑
import { useState, useEffect } from 'react'
import './styles/app.styl'
import TodoList from './components/TodoList'
import TodoInput from './components/TodoInput'
import TodoStats from './components/TodoStats'

function App() {
  // ✅ 关键:初始化时从 localStorage 读取数据,形成持久化闭环的第一步
  const [todos, setTodos] = useState(() => {
    const saved = localStorage.getItem('todos')
    return saved ? JSON.parse(saved) : []
  })

  const addTodo = (text) => {
    if (!text.trim()) return
    setTodos([...todos, {
      id: Date.now(),
      text,
      completed: false,
    }])
  }

  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id))
  }

  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ))
  }

  const clearCompleted = () => {
    setTodos(todos.filter(todo => !todo.completed))
  }

  const activeCount = todos.filter(t => !t.completed).length
  const completedCount = todos.length - activeCount

  // ⚠️ 注意:这段代码仅完成「写入」,必须配合初始化读取才能实现完整持久化
  useEffect(() => {
    localStorage.setItem('todos', JSON.stringify(todos))
  }, [todos])

  return (
    <div className="todo-app">
      <h1>My Todo List</h1>
      <TodoInput onAdd={addTodo} />
      <TodoList 
        todos={todos}  
        onDelete={deleteTodo}
        onToggle={toggleTodo}
      />
      <TodoStats 
        total={todos.length}
        active={activeCount}
        completed={completedCount}
        onClearCompleted={clearCompleted}
      />
    </div>
  )
}

export default App

🔍 本地存储的完整闭环:读 + 写缺一不可

你可能会问:仅靠 useEffect 中的 localStorage.setItem 能实现本地存储吗?

答案是:不能。

  • useEffect 部分只负责“写入” :当 todos 变化时,自动同步到 localStorage
  • 但缺少“读取”环节:页面刷新后,若不从 localStorage 恢复数据,todos 会重置为空数组,导致数据丢失。

完整持久化 = 初始化读取 + 变化时写入

js
编辑
// 1. 初始化读取(关键!)
const [todos, setTodos] = useState(() => {
  const saved = localStorage.getItem('todos');
  return saved ? JSON.parse(saved) : [];
});

// 2. 变化时写入(你已有的代码)
useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

💡 本质:这是一个“内存 ↔ 本地存储”的双向同步闭环。只有两者都存在,才能实现“刷新不丢数据”

⚠️ 注意事项

  • JSON 序列化限制todos 中只能包含可序列化的数据(字符串、数字、布尔值、普通对象/数组),不能包含函数、Symbol 等。
  • 存储容量localStorage 通常限制为 5MB,适合轻量级数据。
  • 性能优化:避免因状态引用变化导致 useEffect 频繁触发(可通过 useMemo 或深比较优化)。

✅ 状态管理的核心原则:子组件不能直接修改数据

你的理解完全正确:子组件不可以直接修改父组件的状态,只能“提交修改请求” 。这是 React 单向数据流的基石。

为什么这样设计?

  1. 状态不可变性(Immutability)
    React 要求状态更新必须通过 setState 返回新对象,而非直接修改原对象。若子组件直接操作 props.todos.push(...),既违反此原则,也无法触发重渲染。
  2. 状态变更可追溯
    所有修改逻辑集中在父组件(如 addTodo, deleteTodo),便于调试、测试和维护。
  3. 解耦与复用
    子组件只需关心“何时触发”,无需关心“如何修改”,降低耦合度。

工作流程比喻

  • 父组件 = 仓库管理员
    持有 todos(货物清单),掌握所有操作权限(增删改查),并负责同步到 localStorage(纸质台账)。
  • 子组件 = 前台接待员
    无权直接操作仓库,只负责接收用户指令(点击按钮),并通过预设的“热线电话”(回调函数如 onAdd)将请求转达给管理员。
  • 流程
    用户操作 → 子组件调用回调 → 父组件更新状态 → 触发重渲染 + 同步本地存储 → 子组件接收新 props 并更新视图。

✅ 这种设计让整个应用状态清晰、逻辑集中、易于扩展


📝 子组件 1:TodoInput —— 输入新任务

jsx
编辑
import { useState } from 'react'

const TodoInput = ({ onAdd }) => {
  const [inputValue, setInputValue] = useState('')

  const handleSubmit = (e) => {
    e.preventDefault() // 阻止表单默认提交(页面刷新)
    onAdd(inputValue)
    setInputValue('') // 清空输入框
  }

  return (
    <form className="todo-input" onSubmit={handleSubmit}>
      <input 
        type="text" 
        value={inputValue}
        onChange={e => setInputValue(e.target.value)}
        placeholder="What needs to be done?"
      />
      <button type="submit">Add</button>
    </form>
  )
}

export default TodoInput

🔍 深入理解两个核心机制

1. 为什么需要 e.preventDefault()

  • 根本原因<form> 提交会触发浏览器默认行为——刷新页面
  • 若不阻止,刚添加的 todo 会因页面刷新而丢失。
  • 关键原则e.preventDefault() 的使用取决于事件是否有默认行为,与“是否是添加操作”无关。

不需要 preventDefault 的添加场景

jsx
编辑
// 按钮点击(无默认行为)
<button onClick={() => { onAdd(text); }}>添加</button>

// 输入框回车监听(非表单提交)
<input onKeyDown={(e) => {
  if (e.key === 'Enter') onAdd(inputValue);
}} />

2. 为什么 setInputValue('') 能清空输入框?

因为这是 受控组件(Controlled Component)

  • value={inputValue}:输入框显示由 React 状态驱动。
  • onChange:用户输入时同步更新状态。
  • 执行 setInputValue('') → 状态变空 → 组件重渲染 → 输入框显示为空。

❌ 非受控组件(用 ref)无法通过此方式清空,需直接操作 DOM。


📋 子组件 2:TodoList —— 渲染任务项

jsx
编辑
const TodoList = ({ todos, onDelete, onToggle }) => {
  return (
    <ul className="todo-list">
      {todos.length === 0 ? (
        <li><p className="empty">No todos, yet!</p></li>
      ) : (
        todos.map(todo => (
          <li 
            key={todo.id} 
            className={todo.completed ? 'completed' : ''}
          >
            <label>
              <input 
                type="checkbox" 
                checked={todo.completed}
                onChange={() => onToggle(todo.id)}
              />
              <span>{todo.text}</span>
            </label>
            <button onClick={() => onDelete(todo.id)}>×</button>
          </li>
        ))
      )}
    </ul>
  )
}

export default TodoList

🔍 删除逻辑深度解析

1. filter 如何实现“删除”?

  • filter 返回新数组,保留 todo.id !== id 的项。
  • 目标项因 id 相等被过滤掉 → 间接实现删除。
  • 符合 React 不可变更新原则。

2. 为什么依赖 id 的唯一性?

  • id: Date.now() 仅在创建时执行一次,作为该 todo 的永久标识。
  • 删除时传递的是原始 id,不是当前时间戳。
  • 因此 todo.id !== id 能精准匹配唯一目标。

⚠️ 风险:若多个 todo 共享相同 id,会导致误删。生产环境建议用 uuid

3. “唯一 id” 与 “id 不同” 是否矛盾?

不矛盾!

  • 唯一 id:确保每个 todo 有唯一身份(目标项只有一个)。
  • id ≠ 目标 id:是 filter 的筛选条件(保留非目标项)。

🧩 类比:从 [小明(id=1), 小红(id=2)] 中删除小红 → 筛选“id ≠ 2” → 结果:[小明]


📊 子组件 3:TodoStats —— 显示统计 & 清除操作

jsx
编辑
const TodoStats = ({ total, active, completed, onClearCompleted }) => {
  return (
    <div className="todo-stats">
      <p>Total: {total} | Active: {active} | Completed: {completed}</p>
      {completed > 0 && (
        <button onClick={onClearCompleted} className="clear-btn">
          Clear Completed
        </button>
      )}
    </div>
  )
}

export default TodoStats

✅ 条件渲染提升用户体验。


🔁 组件通信机制总结

通信方向 实现方式
父 → 子 props 传递数据
子 → 父 props 传递回调函数
兄弟组件 通过父组件状态中转

核心:数据自上而下,事件自下而上


🧪 注意事项与最佳实践

  1. 状态不可变性:始终返回新数组/对象。
  2. ID 生成可靠性:高频场景用 uuid 替代 Date.now()
  3. 表单防空提交disabled={!inputValue.trim()}
  4. 性能优化React.memo + useCallback(按需)

🔍 拓展思考:状态管理方案对比

方案 适用场景 优缺点
状态提升 小型应用 ✅ 简单;❌ 状态集中
Context 跨多层组件 ✅ 避免 props drilling;❌ 更新粒度粗
Zustand/Redux 大型应用 ✅ 强大;❌ 成本高

✅ Todo 应用:状态提升 + localStorage 最佳。


✅ 总结要点

  • ✅ 本地存储 = 初始化读取 + 变化时写入,缺一不可。
  • ✅ e.preventDefault()  仅用于阻止浏览器默认行为(如表单刷新)。
  • ✅ 受控组件 是 setInputValue('') 清空输入框的根本原因。
  • ✅ filter 删除 依赖 唯一且不变的 ID,本质是“保留非目标项”。
  • ✅ 子组件不能直接修改状态,只能通过回调“提交请求”,父组件统一处理。
  • ✅ 父组件持有状态 是实现状态共享与持久化的简洁载体,但核心是完整的持久化闭环

Vite项目中process报红问题的深层原因与解决方案

作者 shanLion
2025年12月24日 17:25

在使用Vite构建的前端项目中,process对象在某些文件中报红(如VSCode中显示错误)是常见问题。本文将系统阐述该问题的深层原因及解决方案,涵盖环境配置、依赖管理与代码兼容性三大核心环节。通过规范化流程,确保process对象在所有文件中稳定可用,避免开发中断。

深层原因分析

1. ‌TypeScript类型检查机制

  • TypeScript的严格类型检查‌:TypeScript默认不识别Node.js内置对象(如process),导致在浏览器环境中报红。例如,process仅在Node.js环境中有效,而Vite默认将代码视为浏览器环境,引发类型冲突。
  • 环境隔离问题‌:Vite通过ESM模块系统运行代码,与Node.js的CommonJS环境隔离,导致TypeScript无法识别process的全局变量。

2. ‌Vite的预构建机制

  • 预构建冲突‌:Vite在构建时会预处理依赖,若未正确配置,可能导致Node.js内置模块(如process)与浏览器环境冲突。例如,process在预构建阶段被误判为浏览器环境变量,引发报红。
  • 环境变量处理‌:Vite的import.meta.env机制仅支持静态环境变量(如.env文件),而process是动态全局对象,需显式声明。

解决方案

解决方案

1. ‌环境配置调整

  • 强制声明全局变量‌:在项目根目录创建vite.env.d.ts文件,显式声明process类型:

    typescriptCopy Code
    // vite.env.d.ts
    /// <reference types="node" />
    

    此声明告知TypeScriptprocess是Node.js内置对象,避免类型冲突。

  • 排除Node.js模块‌:在vite.config.ts中排除process模块,避免预构建冲突:

    javascriptCopy Code
    // vite.config.ts
    export default {
      optimizeDeps: {
        exclude: ['process']
      }
    }
    

    此配置确保process仅在Node.js环境中生效,避免浏览器环境报错。

2. ‌依赖管理优化

  • 安装类型定义‌:运行pnpm i @types/node -D安装Node.js类型定义,增强TypeScript识别能力。例如,在Vue3项目中,此命令可解决process报红问题。

  • 动态导入处理‌:在关键逻辑中添加环境判断,避免process在浏览器中报错:

    javascriptCopy Code
    // utils.js
    if (typeof process !== 'undefined' && process.env.NODE_ENV === 'development') {
      console.log('Development mode');
    }
    

    此方法确保process仅在Node.js环境中生效,避免浏览器环境报错。

3. ‌代码兼容性增强

  • 环境变量声明‌:在项目入口文件(如main.js)中显式声明process对象:

    javascriptCopy Code
    // main.js
    globalThis.process = globalThis.process || { env: {} };
    

    此声明确保process在所有文件中可用,避免编译器误判。

  • 条件编译处理‌:在关键逻辑中添加环境判断,避免process在浏览器中报错:

    javascriptCopy Code
    // utils.js
    if (typeof process !== 'undefined' && process.env.NODE_ENV === 'development') {
      console.log('Development mode');
    }
    

    此方法确保process仅在Node.js环境中生效,避免浏览器环境报错。

总结

Vite 的配置文件 vite.config.js 本质上是 ‌Node.js 环境下的 JavaScript 文件‌,它在构建时由 Node.js 直接执行,而非经过 TypeScript 编译器(tsc)类型检查。即使你使用的是 .js 后缀,Vite 也会在 Node.js 运行时环境中加载它,此时 process 是 Node.js 内置的全局对象,即使未在tsconfig.json中显式添加"types": ["node"],也会默认注入Node.js的全局类型定义(如process__dirname等)。无需类型声明即可使用。

此外,Vite 在启动时会自动注入 process.env 的环境变量,并在构建阶段将其替换为静态值,因此它天然支持 process 对象,无需额外类型定义。

而其他 .ts.tsx 文件中使用 process.env.NODE_ENV 时,TypeScript 会进行严格的类型检查。此时,TypeScript 并不知道 process 是什么——因为它默认不包含 Node.js 的全局类型定义。除非你显式告诉 TypeScript:“这个项目运行在 Node.js 环境中”,否则它会认为 process 未定义,从而报错:“找不到名称‘process’”。

场景 文件类型 执行环境 是否需要 @types/node
vite.config.js JavaScript Node.js 运行时 ❌ 不需要(由 Node.js 直接执行)
src/*.ts TypeScript TypeScript 编译器检查 ✅ 必须安装并配置

React 父子组件数据传递:机制与意义解析

作者 冻梨政哥
2025年12月24日 17:23

React 父子组件数据传递:机制与意义解析

在 React 组件化开发中,组件间的通信是构建复杂应用的核心环节。本文以一个 Todo 列表应用为例,详细解析父子组件的数据传递方式及其背后的设计意义。

一、组件结构与通信场景

在提供的 Todo 应用中,存在明确的组件层级关系:

  • 父组件App 组件作为根组件,是整个应用的核心
  • 子组件TodoInput(输入框)、TodoList(列表展示)、TodoStatus(状态统计)三个子组件,均由 App 组件直接渲染

这些组件需要协同工作:输入框添加待办项、列表展示所有项、统计区显示数量并提供清除功能。这种协同依赖于组件间的数据传递。

二、父子组件数据传递的核心方式

React 中父子组件的通信遵循单向数据流原则,通过 props 实现数据传递,具体分为两种方向:

1. 父组件 → 子组件:通过 props 传递数据

父组件将需要共享的数据通过 props 传递给子组件,子组件通过接收 props 来使用这些数据。

示例解析

  • 在 App 组件中,定义了核心数据 todos(待办项列表),以及基于 todos 计算的统计数据(totalactivecompleted
  • 父组件通过 props 将这些数据传递给子组件:
// App.jsx 中传递数据给子组件
<TodoList todos={todos} ... />
<TodoStatus total={todos.length} active={activeCount} completed={completedCount} ... />

子组件通过解构 props 接收并使用数据:

// TodoList 接收并展示 todos 数据
const { todos } = props;
// 渲染 todos 列表
todos.map(todo => (...))

// TodoStatus 接收并展示统计数据
const { total, active, completed } = props;
<p>Total: {total} | Active: {active} | Completed: {completed}</p>

2. 子组件 → 父组件:通过回调函数传递数据变更请求

子组件不能直接修改父组件传递的数据(React 中 props 是只读的),需通过调用父组件传递的回调函数,将数据变更的需求传递给父组件,由父组件负责更新数据。

示例解析

  • 父组件定义修改数据的方法(如 addTododeleteTodo),并通过 props 将这些方法传递给子组件:
// App.jsx 中定义方法并传递
const addTodo = (text) => {
  setTodos([...todos, { id: Date.now(), text, completed: false }]);
};

<TodoInput onAdd={addTodo} />
<TodoList onDelete={deleteTodo} onToggle={toggleTodo} />

子组件触发用户操作时,调用父组件传递的回调函数,传递需要变更的数据:

// TodoInput 接收 onAdd 方法,在提交时调用
const { onAdd } = props;
const handleSubmit = (e) => {
  e.preventDefault();
  onAdd(inputValue); // 将输入的文本传递给父组件
  setInputValue('');
};

// TodoList 接收 onDelete 方法,在点击删除时调用
<button onClick={() => onDelete(todo.id)}>X</button>

三、这种传递方式的核心意义

  1. 数据集中管理,保持单一数据源所有核心数据(todos)由父组件 App 统一持有和管理,子组件仅通过 props 获取数据或请求修改。这种设计避免了数据分散在多个组件中导致的 "数据混乱",便于追踪数据的变更历史。

  2. 明确组件职责,实现关注点分离

    • 父组件:专注于数据的存储、计算和更新逻辑(如 addTododeleteTodo 方法)
    • 子组件:专注于 UI 展示和用户交互(如 TodoInput 处理输入、TodoList 展示列表)职责分离让组件更易于维护和复用,例如 TodoList 仅依赖传入的 todos 和回调函数,可在其他场景中直接复用。
  3. 单向数据流,提升可预测性数据只能从父组件流向子组件,子组件的变更需求必须通过父组件处理。这种单向流动让应用的状态变化可预测,当出现问题时,能快速定位到数据变更的源头(父组件的方法),降低调试难度。

  4. 间接实现兄弟组件通信兄弟组件(如 TodoInput 和 TodoList)本身无法直接通信,但通过父组件作为 "中介",可间接实现数据同步。例如:TodoInput 添加新项后,父组件更新 todosTodoList 因接收的 todos 变化而重新渲染,实现了兄弟组件的状态协同。

从零构建一个待办事项应用:一次关于组件化与状态管理的深度思考

作者 烟袅破辰
2025年12月24日 17:09

我们今天要做的,是一个再普通不过的小项目——待办事项(Todo List)应用
它看起来简单:输入任务、添加、勾选完成、删除、清空已完成项。但正是这种“简单”,恰恰是理解 React 核心思想的最佳载体。


一、如果把所有代码都写在 App.jsx 中,会怎样?

假设我们不拆分组件,直接在 App.jsx 里写完所有的逻辑:

function App() {
  const [todos, setTodos] = useState([]);
  // 添加、删除、切换……全部在这里处理
  return (
    <div>
      <input onKeyPress={...} />
      <button onClick={...}>Add</button>
      <ul>
        {todos.map(...)}
      </ul>
    </div>
  );
}

这会带来什么问题?

可读性下降,维护成本飙升。

当我们需要修改“输入框样式”时,得翻遍整个文件找 input
当我们要支持“编辑任务”功能时,又要在这堆代码中插入新逻辑;
更别说以后加搜索、分类、拖拽排序了……

所以,第一个问题来了:

我们能不能把这个应用拆成几个独立的组件?每个组件负责什么?


二、组件拆解:职责分离才是王道

经过一番思考,我们可以将这个应用划分为三个清晰的部分:

  1. TodoInput —— 负责用户输入和添加新任务;
  2. TodoList —— 负责展示所有任务,并提供勾选和删除操作;
  3. TodoStats —— 展示统计信息(总数、未完成数),并提供“清空已完成”的按钮。

这样做的好处显而易见:

  • 每个组件只关心自己的 UI 和行为;
  • 修改一处不会影响其他部分;
  • 未来复用也更容易(比如另一个项目也需要一个输入框)。

那么下一个问题就来了:

这些组件都需要访问同一个数据 todos,那谁来管理这个数据呢?


三、状态归属:谁该拥有 todos 的控制权?

如果我们让 TodoInput 自己维护 todos,那 TodoList 就无法知道当前有哪些任务了;
如果 TodoList 管理,那 TodoInput 又没法添加新任务。

于是我们得出结论:

todos 必须由最顶层的 App 组件统一管理。

因为它是唯一能同时向所有子组件传递数据和回调函数的地方。

这其实就是 React 的核心设计哲学之一:单向数据流 + 状态提升(State Hoisting)

App 是“数据源”,通过 props 把数据传给子组件,子组件通过回调通知父组件更新。


四、深入分析:TodoInput 组件为什么封装 onAdd

我们来看 TodoInput 的实现:

const TodoInput = (props) => {
  const { onAdd } = props;
  const [inputValue, setInputValue] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    onAdd(inputValue);
    setInputValue('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={inputValue} onChange={(e) => setInputValue(e.target.value)} />
      <button type="submit">Add</button>
    </form>
  );
};

这里有个关键点:我们没有直接在按钮上绑定 onAdd,而是先封装了一个 handleSubmit 函数。

这是为什么?

如果我们直接写 <button onClick={() => onAdd(inputValue)}>Add</button> 呢?

听起来也没错,但有两个隐患:

  1. 无法阻止默认提交行为:表单提交会导致页面刷新(除非手动调用 preventDefault());
  2. 无法统一处理后续逻辑:比如提交后清空输入框,必须重复写两次(按钮和回车)。

而使用 <form onSubmit> 则天然解决了这两个问题:

  • 浏览器原生支持“回车提交”;
  • 所有提交逻辑集中在一个函数中,避免重复。

所以,handleSubmit 不只是“包装一下”,而是为了统一处理提交流程,确保用户体验一致、代码简洁可靠。


五、TodoList:如何高效地渲染和交互?

const TodoList = ({ todos, onDelete, onToggle }) => {
  return (
    <ul>
      {todos.length === 0 ? (
        <li className="empty">No todos yet!</li>
      ) : (
        todos.map(todo => (
          <li key={todo.id} className={todo.completed ? 'completed' : ''}>
            <label>
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => onToggle(todo.id)}
              />
              <span>{todo.text}</span>
            </label>
            <button onClick={() => onDelete(todo.id)}>Delete</button>
          </li>
        ))
      )}
    </ul>
  );
};

这里有几个值得思考的设计:

  • key={todo.id} :React 需要稳定键值来识别列表项,防止重新渲染时出现闪烁;
  • checked={todo.completed} :使用受控组件,保证 UI 与状态同步;
  • onChange 触发 onToggle:不是直接改 completed,而是通知父组件更新状态,保持单一数据源。

有人可能会问:“为什么不直接在 input 上写 checked={todo.completed} 并监听 onChange?”
答案是:可以!但必须配合 useStatesetTodos 来更新状态,否则就是“不受控组件”,违背 React 设计原则。


六、TodoStats:极简但不可忽视的统计层

const TodoStats = ({ total, active, completed, onClearCompleted }) => {
  return (
    <div className="todo-stats">
      <p>Total: {total} | Active: {active} | Completed: {completed}</p>
      {completed > 0 && (
        <button onClick={onClearCompleted} className="clear-btn">
          Clear Completed
        </button>
      )}
    </div>
  );
};

这个组件看似简单,但它承担着两个重要角色:

  1. 信息聚合:将原始数据转化为有意义的统计;
  2. 行为触发:提供“清空已完成”的入口。

它的存在告诉我们:即使是最小的功能模块,也应该被抽象为独立组件,才能更好地组织代码结构。


七、终极难题:页面刷新后数据全丢?怎么办?

现在我们面临一个现实问题:

每次刷新页面,todos 都变成空数组了!

这是因为在浏览器中,JavaScript 的内存是临时的,一旦关闭页面或刷新,状态就会丢失。

那有没有办法让数据持久化?

当然有——localStorage

但我们不想在每次 addTododeleteTodotoggleTodo 时都手动调用 localStorage.setItem(),那样太繁琐。

那有没有更优雅的方式?

答案是:结合 useEffect 使用!

useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

这段代码的意思是:

“只要 todos 发生变化,就自动保存到 localStorage。”

这带来了巨大的便利:

  • 不需要在每一个方法里重复写存储逻辑;
  • 数据变更即保存,几乎无感知;
  • 符合“副作用”处理的最佳实践。

而且我们还可以在初始化时从 localStorage 加载数据:

const [todos, setTodos] = useState(() => {
  const saved = localStorage.getItem('todos');
  return saved ? JSON.parse(saved) : [];
});

这样一来,打开页面时就能恢复上次的状态,用户体验大大提升。


八、总结:这不是一个简单的 Todo 应用

表面上看,这是一个入门级的练习项目。但实际上,它涵盖了 React 开发中的多个核心思想:

概念 在本项目中的体现
组件化 拆分为 Input/List/Stats 三个独立组件
状态管理 todosApp 统一管理,通过 props 传递
受控组件 输入框用 valueonChange 控制,确保数据一致性
表单提交优化 使用 form onSubmit 实现统一提交逻辑
状态持久化 结合 localStorageuseEffect 实现数据保存
不可变性 使用 mapfilter 创建新数组,而非修改原数组

Vue.js 插槽机制深度解析:从基础使用到高级应用

2025年12月24日 16:37

引言:组件化开发中的灵活性与可复用性

在现代前端开发中,组件化思想已成为构建复杂应用的核心范式。Vue.js作为一款渐进式JavaScript框架,提供了强大而灵活的组件系统。然而,在组件通信和数据传递方面,单纯的props和事件机制有时难以满足复杂场景的需求。这时,Vue的插槽(Slot)机制便显得尤为重要。本文将通过分析提供的代码示例,深入探讨Vue插槽的工作原理、分类及应用场景。

一、插槽的基本概念与作用

1.1 什么是插槽

插槽是Vue组件化体系中的一项关键特性,它允许父组件向子组件指定位置插入任意的HTML结构。这种机制本质上是一种组件间通信的方式,但其通信方向与props相反——是从父组件到子组件的内容传递。

readme.md中所定义的,插槽的核心作用是"挖坑"与"填坑"。子组件通过<slot>标签定义一个"坑位",而父组件则负责用具体内容来"填充"这个坑位。这种设计模式极大地增强了组件的灵活性和可复用性。

1.2 为什么需要插槽

在传统的组件设计中,子组件的内容通常是固定的,或者只能通过props传递简单的数据。但在实际开发中,我们经常遇到这样的需求:组件的基本结构相同,但内部内容需要根据使用场景灵活变化。

例如,一个卡片组件(Card)可能有统一的标题样式、边框阴影等,但卡片的主体内容可能是文本、图片、表单或任何其他HTML结构。如果没有插槽机制,我们需要为每种内容类型创建不同的组件,或者通过复杂的条件渲染逻辑来处理,这都会导致代码冗余和维护困难。

二、默认插槽:最简单的插槽形式

2.1 默认插槽的基本用法

观察第一个App.vue文件中的代码:

vue

复制下载

<template>
  <div class="container">
    <MyCategory title="美食">
      <img src="./assets/logo.png" alt="">
    </MyCategory>
    <MyCategory title="游戏">
      <ul>
        <li v-for="(game,index) in games" :key="index">{{ game }}</li>
      </ul>
    </MyCategory>
  </div>
</template>

在第一个MyCategory.vue中,子组件的定义如下:

vue

复制下载

<template>
  <div class="category">
    <h3>{{ title}}</h3>
    <slot>我是默认插槽(挖个坑,等着组件的使用者进行填充)</slot>
  </div>
</template>

这里展示的是默认插槽的使用方式。当父组件在<MyCategory>标签内部放置内容时,这些内容会自动填充到子组件的<slot>位置。

2.2 默认内容与空插槽处理

值得注意的是,<slot>标签内部可以包含默认内容。当父组件没有提供插槽内容时,这些默认内容会被渲染。这为组件提供了良好的降级体验,确保组件在任何情况下都有合理的显示。

三、作用域插槽:数据与结构的解耦

3.1 作用域插槽的核心思想

作用域插槽是Vue插槽机制中最强大但也最复杂的概念。如其名所示,它解决了"作用域"问题——数据在子组件中,但如何展示这些数据却由父组件决定。

在第二个App.vue文件中,我们看到了作用域插槽的实际应用:

vue

复制下载

<template>
  <div class="container">
    <MyCategory title="游戏">
      <template v-slot="{games}">
        <ul>
          <li v-for="(game,index) in games" :key="index">{{ game }}</li>
        </ul>
      </template>
    </MyCategory>
    
    <MyCategory title="游戏">
      <template v-slot="{games}">
        <ol>
          <li v-for="(game,index) in games" :key="index">{{ game }}</li>
        </ol>
      </template>
    </MyCategory>
  </div>
</template>

对应的子组件MyCategory.vue(第二个版本)为:

vue

复制下载

<template>
  <div class="category">
    <h3>{{ title}}</h3>
    <slot :games="games">我是默认插槽</slot>
  </div>
</template>

<script>
export default {
  name:'MyCategory',
  props:['title'],
  data(){
    return{
      games: ['王者荣耀','和平精英','英雄联盟'],
    }
  }
}
</script>

3.2 作用域插槽的工作原理

作用域插槽的精妙之处在于它实现了数据与表现层的分离:

  1. 数据在子组件:游戏数据games是在MyCategory组件内部定义和维护的
  2. 结构在父组件决定:如何展示这些游戏数据(用<ul>还是<ol>,或者其他任何结构)由父组件决定
  3. 通信通过插槽prop:子组件通过<slot :games="games">将数据"传递"给插槽内容

这种模式特别适用于:

  • 可复用组件库的开发
  • 表格、列表等数据展示组件的定制化
  • 需要高度可配置的UI组件

3.3 作用域插槽的语法演变

在Vue 2.6.0+中,作用域插槽的语法有了统一的v-slot指令。上述代码中使用的就是新语法:

vue

复制下载

<template v-slot="{games}">
  <!-- 使用games数据 -->
</template>

这等价于旧的作用域插槽语法:

vue

复制下载

<template slot-scope="{games}">
  <!-- 使用games数据 -->
</template>

四、插槽的高级应用与最佳实践

4.1 具名插槽:多插槽场景的解决方案

虽然提供的代码示例中没有展示具名插槽,但readme.md中已经提到了它的基本用法。具名插槽允许一个组件有多个插槽点,每个插槽点有独立的名称。

具名插槽的典型应用场景包括:

  • 布局组件(头部、主体、底部)
  • 对话框组件(标题、内容、操作按钮区域)
  • 卡片组件(媒体区、标题区、内容区、操作区)

4.2 插槽的编译作用域

理解插槽的编译作用域至关重要。父级模板里的所有内容都是在父级作用域中编译的;子模板里的所有内容都是在子作用域中编译的。这意味着:

  1. 父组件无法直接访问子组件的数据
  2. 子组件无法直接访问父组件的数据
  3. 插槽内容虽然最终出现在子组件的位置,但它是在父组件的作用域中编译的

这也是作用域插槽存在的根本原因——为了让父组件能够访问子组件的数据。

4.3 动态插槽名与编程式插槽

Vue 2.6.0+还支持动态插槽名,这为动态组件和高度可配置的UI提供了可能:

vue

复制下载

<template v-slot:[dynamicSlotName]>
  <!-- 动态内容 -->
</template>

4.4 插槽的性能考量

虽然插槽提供了极大的灵活性,但过度使用或不当使用可能会影响性能:

  1. 作用域插槽的更新:作用域插槽在每次父组件更新时都会重新渲染,因为插槽内容被视为子组件的一部分
  2. 静态内容提升:对于静态的插槽内容,Vue会进行优化,避免不必要的重新渲染
  3. 合理使用v-once:对于永远不会改变的插槽内容,可以考虑使用v-once指令

五、实际项目中的插槽应用模式

5.1 布局组件中的插槽应用

在实际项目中,插槽最常见的应用之一是布局组件。例如,创建一个基础布局组件:

vue

复制下载

<!-- BaseLayout.vue -->
<template>
  <div class="base-layout">
    <header>
      <slot name="header"></slot>
    </header>
    <main>
      <slot></slot>
    </main>
    <footer>
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

5.2 高阶组件与渲染委托

作用域插槽可以用于实现高阶组件模式,将复杂的渲染逻辑委托给父组件:

vue

复制下载

<!-- DataProvider.vue -->
<template>
  <div>
    <slot :data="data" :loading="loading" :error="error"></slot>
  </div>
</template>

<script>
export default {
  data() {
    return {
      data: null,
      loading: false,
      error: null
    }
  },
  async created() {
    // 获取数据逻辑
  }
}
</script>

5.3 组件库开发中的插槽设计

在组件库开发中,合理的插槽设计可以极大地提高组件的灵活性和可定制性:

  1. 提供合理的默认插槽:确保组件开箱即用
  2. 定义清晰的具名插槽:为常用定制点提供专用插槽
  3. 暴露必要的作用域数据:通过作用域插槽提供组件内部状态
  4. 保持向后兼容:新增插槽不应破坏现有使用方式

六、插槽与其他Vue特性的结合

6.1 插槽与Transition

插槽内容可以应用Vue的过渡效果:

vue

复制下载

<Transition name="fade">
  <slot></slot>
</Transition>

6.2 插槽与Teleport

Vue 3的Teleport特性可以与插槽结合,实现内容在DOM不同位置的渲染:

vue

复制下载

<template>
  <div>
    <slot></slot>
    <Teleport to="body">
      <slot name="modal"></slot>
    </Teleport>
  </div>
</template>

6.3 插槽与Provide/Inject

在复杂组件层级中,插槽可以与Provide/Inject API结合,实现跨层级的数据传递:

vue

复制下载

<!-- 祖先组件 -->
<template>
  <ChildComponent>
    <template v-slot="{ data }">
      <GrandChild :data="data" />
    </template>
  </ChildComponent>
</template>

七、总结与展望

Vue的插槽机制是组件化开发中不可或缺的一部分。从最简单的默认插槽到灵活的作用域插槽,它们共同构成了Vue组件系统的强大内容分发能力。

通过本文的分析,我们可以看到:

  1. 默认插槽提供了基本的内容分发能力,适用于简单的内容替换场景
  2. 作用域插槽实现了数据与表现的彻底分离,为高度可定制的组件提供了可能
  3. 具名插槽解决了多内容区域的组件设计问题

随着Vue 3的普及,插槽API更加统一和强大。组合式API与插槽的结合,为组件设计带来了更多可能性。未来,我们可以期待:

  1. 更优的性能:编译时优化进一步减少插槽的运行时开销
  2. 更好的TypeScript支持:作用域插槽的完整类型推导
  3. 更丰富的生态:基于插槽模式的更多最佳实践和工具库

# Vue3 图片标注插件 AILabel

作者 叫我AddV
2025年12月23日 15:58

Vue3 图片标注插件 AILabel

近期在做一个图片标注的项目,就是展示一张图片,然后在图片上拖拖拽拽绘制一个一个的框框,然后把框框的位置数据保存起来。

插件 AILabel

NPM:www.npmjs.com/package/ail… GitHub:github.com/jlifeng/ail…

在这里插入图片描述

但是有一个问题啊,就是这个 github 上面或者是 npm 上面的链接都是失效的了,文档都找不到,下面贴一个我弄到的文档,起码我发文的时候还是可以查看的,不知道后期还能不能保住!

文档:luchuanqi.github.io/AILabel/doc…

安装 AILabel 插件

安装插件很简单哈,一行命令完事儿了。

npm i ailabel

静待安装完成就可以了。

使用(关键代码)

AILabel 使用类似于openlayer,也是那种一个一个的图层往上加,下面简单来个案例哈!

1. 获取图片,获取原始图片的宽高

比如我有一张图片,我需要获取这个图片的宽高是多少,因为我必须于原始图片大小一样,不然的话我标注出来的位置就是不准的,因此需要先获取原始图片宽高。

const url = ref("http://192.168.78.17:5173/static/images/1.jpg")  // 图片地址

// 加载图片 获取图片宽高
const loadImage = () => {
  const img = new Image()
  img.src = url.value
  img.onload = () => {
    imageWidth.value = img.width  // 图片宽度
    imageHeight.value = img.height   // 图片高度
  }
}

2. 初始化 AILabel

初始化标注就是使用 AILabel,然后把图片作为图层中添加到 AILabel 中,同时需要在 AILabel 中添加一个标注图层。

// 初始化标注
const initAnno = () => {
  gMap.value = new AILabel.Map('ed-video-mask', {
    center: { x: imageWidth.value / 2, y: imageHeight.value / 2 },  // 设置一下中心点位置
    zoom: imageWidth.value,  // zoom下面单独说,很重要
    mode: 'PAN',   // 设置类型为平移(可以设置绘制矩形、多边形、圆形等等,默认拖拽就行了)
    refreshDelayWhenZooming: true,  // 这些在文档里面看就行
    zoomWhenDrawing: false,
    panWhenDrawing: false,
    withHotKeys: false
  })

  // 图片图层 (用于展示图片)
  gImageLayer.value = new AILabel.Layer.Image('image-layer', {
    src: url.value,  // 图片地址url (比如:https://xxx.com/1.png)
    width: imageWidth.value,  // 图片实际宽度
    height: imageHeight.value,   // 图片实际高度
    crossOrigin: false,
    position: { x: 0, y: 0 }  // 初始位置
  },
    { name: '图片图层' },
    { zIndex: 1 })

  gMap.value.addLayer(gImageLayer.value)  // 图片图层添加到 AILabel

  // 标注图层 (绘制的框框在这个图层上)
  gFeatureLayer.value = new AILabel.Layer.Feature(
    'feature-layer',
    { name: '标注图层' },
    { zIndex: 2 }
  )
  gMap.value.addLayer(gFeatureLayer.value)   // 标注图层添加到 AILabel
}

这样就可以了。

那个 zoom 是啥意思哈,很重要,比如我的图片原始分辨率尺寸是 3000 * 2000,但是我们标注给的可是区域不一定这么大啊,也许只有1000* 500,那么我们直接标注的话可能就有问题,标注出来的框框位置可能和实际照片的位置不匹配,这个zoom就是来解决这个问题的。

这个 zoom 就是图片宽度的实际大小

比如,我的标注可视区域可能只有1000px,但是图片实际宽度是3000px,图片为了在可视区域放得下,会自动把图片缩小,让图片可以在可视区域完整得放下。这时候其实图片是被缩小的,但是我们标注出来的位置和大小就和原尺寸的对应不上了,这时候我们设置 zoom 为 3000,那么就对应上了,所以,zoom 设置为原始图片宽度就可以!切记切记!不然标注出来的位置是错的!!!

3. 修改 AILabel 的模式

上面默认设置了 PAN,就是平移,这个时候是可以拖拽图片位置,滚轮实现图片缩放的。

当需要鼠标拖拽绘制的时候,需要改为其他的模式,修改方式为:

gMap.value.setMode(mode)

其中 mode 的值可以是:RECTCIRCLEPOLYGONPOINTLINE

含义
RECT 矩形
CIRCLE 圆形
POLYGON 多边形
POINT
LINE 线段
PAN 平移

4. 设置拖拽样式

在我们设置了 gMap.value.setMode('RECT') 绘制矩形的时候,我们按下鼠标左键拖拽的时候会有一个默认样式,当然这是可以设置的,设置起来很简单:

gMap.value.setDrawingStyle({
strokeStyle: '#409EFF',  // 设置边框颜色
  lineWidth: 2,   // 设置边框宽度
  fillStyle: '#409EFF44',   // 设置填充颜色
  fill: true,    // 启用填充
  stroke: true   // 启用边框
})

5. 事件

事件是啥呢,比如,我绘制完会走哪个函数,选中会走那个函数,修改之后会走哪个函数等等。

我们可以写一个函数绑定这些事件。

// 绑定事件
const bindEvents = () => {
  // 绘制完成回调监听
  gMap.value.events.on('drawDone', (type, data) => {
    createAnnotation(type, data)  // 走了一个函数
  })
  // 要素选中回调监听(非PAN模式下双击选中)
  gMap.value.events.on('featureSelected', (feature) => {
    if (feature) { gMap.value.setActiveFeature(feature) }
  })
  // 要素取消选中回调监听(选中后点击其他位置取消选中)
  gMap.value.events.on('featureUnselected', () => {
    gMap.value.setActiveFeature(null)
  })
  // 要素更新回调监听(选中后编辑完成回调)
  gMap.value.events.on('featureUpdated', (feature, shape) => {
    feature.updateShape(shape)
  })
}

我们开启绘制之后,绘制完一松手发现框框没了,因为我们没有绘制上去,所以我们在绘制完成之后的回调里面,获得了绘制的数据,然后把这个数据自己手写一个框框放到图层上面去:

// 添加要素
const createAnnotation = (type, shape) => {
  const id = `${type}_${uuid4()}`  // 随机ID
  let feature = null
  let style = {
    strokeStyle: '#409EFF',  // 设置边框颜色
 lineWidth: 2,   // 设置边框宽度
    fillStyle: '#409EFF44',   // 设置填充颜色
    fill: true,    // 启用填充
    stroke: true   // 启用边框
  }
  if (type === 'RECT') {   // 绘制矩形
    feature = new AILabel.Feature.Rect(id, shape, null, style)
  } else if (type === 'CIRCLE') {  // 绘制圆形
    feature = new AILabel.Feature.Circle(id, shape, null, style)
  } else if (type === 'POLYGON') {  // 绘制多边形
    feature = new AILabel.Feature.Polygon(id, { points: shape }, null, style)
  } else if (type === 'POINT') {  // 绘制点
    feature = new AILabel.Feature.Point(id, shape, null, style)
  } else if (type === 'LINE') {   // 绘制线
    feature = new AILabel.Feature.Line(id, shape, null, style)
  }
  gFeatureLayer.value.addFeature(feature)
  return feature;
}

然后就可以了。

在这里插入图片描述

Tauri (22)——让 `Esc` 快捷键一层层退出的分层关闭方案

2025年12月23日 10:15

背景:为什么 Esc 会变得“不可控”

在 Coco 里,Esc 同时承担了很多“退出/关闭”的职责:

  • 退出输入(Input/Textarea)编辑态
  • 关闭弹层(Popover / 菜单)
  • 关闭历史面板(History Panel)
  • 最后隐藏窗口(Tauri hideWindow

问题在于:这些层级的 UI 往往同时存在。如果不做事件隔离,Esc 可能 “一键关闭所有”,或者被某一层吞掉导致 “该关的不关”。

本次改动的核心,就是把 Esc 做成可预期的优先级链路,并通过 stopPropagation + DOM 状态判定让各层各司其职。


设计目标:Esc 的层级优先级(从近到远)

我们把 Esc 定义为 “从用户当前操作点开始逐层退出”,优先级如下:

  1. 如果正在输入:先 blur(退出输入态),不做其它关闭
  2. 如果在 Popover 里:关闭当前 Popover(但第一下 Esc 若在输入框里,仍先 blur)
  3. 如果上下文菜单打开:关闭上下文菜单
  4. 如果 History Panel 打开:关闭 History Panel
  5. 如果 Extension View 打开:不隐藏窗口(交给 Extension 自己处理或保持现状)
  6. 否则:隐藏窗口(hideWindow()

这条链路的关键点是:“内层 UI 必须能拦截并消费 Esc,避免冒泡到全局导致直接 hideWindow”


全局 Esc:统一入口与“最后兜底”

全局 EscLayoutOutlet 初始化(src/routes/outlet.tsx 调用 useEscape()),核心逻辑在 src/hooks/useEscape.ts

  • 先做 event.preventDefault() + event.stopPropagation()src/hooks/useEscape.ts
  • 再按优先级处理:
    • 输入 blur(src/hooks/useEscape.ts
    • 关闭右键菜单(src/hooks/useEscape.ts
    • 关闭 History Panel(src/hooks/useEscape.ts
    • Extension 打开时直接 return,避免误隐藏(src/hooks/useEscape.ts
    • 最后隐藏窗口(src/hooks/useEscape.ts

这里有个重要的小优化:回调里用 useSearchStore.getState() 取最新状态(src/hooks/useEscape.ts),避免快捷键回调捕获旧状态导致 “按了没反应”。


Popover 的 Esc:把“关闭弹层”从全局剥离出来

真正决定 “Esc 是否一层层退出” 的关键,是 Popover 对 Esc 的消费。

在 Radix Popover 中,PopoverContent 提供了 onEscapeKeyDown。本次在组件封装层统一加入:

  • 文件:src/components/ui/popover.tsx
  • 逻辑:src/components/ui/popover.tsx

行为是:

  1. stopPropagation + preventDefault(避免 Esc 冒泡到全局 useEscape,也避免浏览器默认行为)
  2. 如果焦点在输入框/文本域:只 blur(src/components/ui/popover.tsx
    • 这保证了“第一下 Esc 退出输入”,而不是直接关掉 popover
  3. 否则:找到当前打开的 popover trigger 并 click(),从而走 Radix 的正常关闭流程(src/components/ui/popover.tsx

为了找到 “当前打开的 trigger”,新增了选择器常量:

  • OPENED_POPOVER_TRIGGER_SELECTORsrc/constants/index.ts

为什么要改选择器:从“自定义标记”到 Radix 的真实 DOM

这次对 Popover 的识别方式也做了统一调整:

  • POPOVER_PANEL_SELECTOR 改为 Radix wrapper:src/constants/index.ts
    • 变成 "[data-radix-popper-content-wrapper]",用于更可靠地判断“现在是否存在 popover”
  • HISTORY_PANEL_ID / CONTEXT_MENU_PANEL_IDheadlessui-... 命名迁移到 popover-panel:...src/constants/index.ts
    • 配合当前实际渲染结构,避免 ID 不一致导致关闭逻辑失效

对应的 History 关闭动作走的是点击 trigger:

  • closeHistoryPanel()src/utils/index.ts
  • 它通过 [aria-controls="${HISTORY_PANEL_ID}"] 找到按钮并点击(src/utils/index.ts
  • useEscapedocument.getElementById(HISTORY_PANEL_ID) 判断历史面板是否存在(src/hooks/useEscape.ts

VisibleKey 与 “在 Popover 里显示快捷键提示”

VisibleKey 需要知道 “当前快捷键提示是否应该显示”,尤其是在 popover 打开时,只在 popover 内的元素上显示。

它通过:

  • POPOVER_PANEL_SELECTOR 获取 popover 面板 wrapper(src/components/Common/VisibleKey.tsx
  • OPENED_POPOVER_TRIGGER_SELECTOR 获取打开的 trigger(src/components/Common/VisibleKey.tsx
  • 判断当前组件是否在 panel 或 trigger 内(src/components/Common/VisibleKey.tsx

这样一来,“打开 popover 后,快捷键提示只在当前 popover 的交互区域出现”,不会污染外层 UI。


输入框组件收敛:删除 PopoverInput,统一用 shadcn Input

src/components/Common/PopoverInput.tsx 被删除,相关位置改为直接使用 src/components/ui/input

  • SearchPopoversrc/components/Search/SearchPopover.tsx
  • MCPPopoversrc/components/Search/MCPPopover.tsx
  • AssistantListsrc/components/Assistant/AssistantList.tsx

同时统一加了 autoCorrect="off",减少输入联想带来的干扰(例如英文关键字/ID 搜索场景)。


小结

本次改动将 Esc 从“不可控的一键退出”重构为有明确优先级的逐层退出机制

输入态 → Popover → 菜单 / History → Extension → 窗口隐藏

核心做法是:

  • 全局 useEscape 只作为最后兜底,按优先级处理关闭逻辑
  • Popover 内部消费 Esc(优先 blur 输入,其次关闭弹层),防止事件冒泡到全局
  • 基于 Radix 实际 DOM 统一判断 popover / history 是否打开
  • VisibleKey 只在当前 popover 交互区域内显示
  • 统一输入组件,减少干扰

结果是:Esc 行为稳定、可预期,不再误关、不再漏关。

开源

如果你对我们的技术细节感兴趣,或者想体验一下这款全能的生产力工具,欢迎访问我们的开源仓库和官网:

不只是作品集:用 Next.js 打造我的数字作品库

2025年12月23日 09:31

前言

Hi,大家好,我是白雾茫茫丶!

很久没和大家见面了,说来惭愧,自从AI成了“全能助手”,我这个“码字工”的笔就有点锈了——感觉很多技术问题,还没等我写完文章,AI三言两语就解释清楚了。少了点输出深度内容的动力,手也就慢慢懒了。

不过最近,我又找到了一点不一样的感觉。工作上接触不到,我就自己动手——这段时间一直在折腾 Next.js + Shadcn UI 这套组合。不得不说,确实很火,用起来也很顺手,从头搭一个项目的过程,反而让我找回了那种边踩坑边学习的踏实感。

今天想和大家分享的,是我在这个过程中做出来的一个小成果:一个个人作品信息展示的模板。我觉得它设计得挺干净,结构也清晰,不管是拿来即用,还是当作学习参考,应该都还不错。如果你也在找类似的灵感或模版,不妨看看,希望能帮到你。

灵感来源

在我探索 Shadcn UI 的过程中,偶然发现了一个设计非常出色的模板:

magicui.design/docs/templa…

最初我只是把它集成在自己正在捣鼓的项目里试试水,但很快发现,这个页面本身就足够完整和优雅——即使独立出来,也完全能作为一个专业的作品展示站点。

于是,我决定把它单独抽离出来,搭建成了一个专注的作品展示项目。在保留其原设计精髓的基础上,我根据自己的偏好做了一些调整,加入了一些个性化的交互细节和动画效果,让整个界面在简洁之中多了一点灵动的气息。

最终呈现出来的,就是现在这个版本——整体保持干净利落,又不失细节处的巧思。如果你也在寻找一个轻量、现代且易于定制的作品集模板,或许这个实现能给你带来一些灵感。

为什么每个开发者都需要一个作品站点?

作为开作为开发者,我们每天都在创造价值:在 GitHub 提交代码、在掘金写技术文章、在开源社区贡献方案……

但这些成果往往散落在不同的平台,像一座座信息孤岛。面试时,我们需要反复解释这些分散的内容;求职时,简历上的短短几行描述,难以承载我们真实的技术思考与项目深度。

构建一个统一的技术身份,将分散的项目、文章、数据可视化整合在一个专业、可访问的空间。它不仅仅是一个作品集,更是:

面试的隐形加分项——当面试官通过一个精心设计的站点看到你的完整技术路径、真实的项目思考过程,这种体验远胜过千篇一律的简历

技术能力的系统证明——可视化你的 GitHub 贡献、技能雷达图、项目迭代历史,让抽象的能力变得具体可见

技术栈

- 框架:Next.js 16、React 19、TypeScript 5
- 样式:Tailwind CSS v4、tw-animate-css
- 可视化:Recharts
- 其他:ahooks、enum-plus、lucide-react

特性

- 基于 `Next.js App Router` 的现代架构
- 使用 `Tailwind CSS v4` 与自定义主题变量,支持暗色模式
- GitHub 仓库与贡献统计 API
- Halo 文章列表聚合 API
- Recharts 数据图表可视化
- 完整 SEO 文件:`robots``sitemap``manifest`
- 集成 Umami、Microsoft Clarity、Google Analytics(生产环境自动启用)

环境变量

在项目根目录创建 .env,示例:

# 站点信息
NEXT_PUBLIC_NAME="你的名字"
NEXT_PUBLIC_APP_NAME="Portfolio"
NEXT_PUBLIC_DESC="一句话简介/站点描述"
NEXT_PUBLIC_APP_DOMAIN="https://your-domain.com"
NEXT_PUBLIC_THEME="light" # 可选:light | dark | system

# 分析统计(生产环境生效)
NEXT_PUBLIC_UMAMI_ID=""
NEXT_PUBLIC_CLARITY_ID=""
NEXT_PUBLIC_GA_ID=""

# GitHub API
GITHUB_TOKEN="" # 只读 Token
NEXT_PUBLIC_GITHUB_USERNAME="your-github-username"

# Halo API
HALO_TOKEN="" # 只读 Token

效果预览

PixPin_2025-12-23_09-27-56.png

总结

这个基于 Next.js + Shadcn UI 构建的个人作品展示模板,是我在探索现代前端技术栈过程中的一次实践与沉淀。

技术本身是工具,但如何用它更好地表达自己、呈现价值,才是更有意义的探索。

如果你也在构建个人项目、整理作品集,或单纯想学习 Next.js 全栈实践,这个项目或许能给你一些参考。

在线预览:portfolio.baiwumm.com

Github:github.com/baiwumm/por…

草稿

作者 三只萌新
2025年12月24日 17:27
使用 基本界面 需求文档 设计文档 任务拆解 执行任务 最终产物包含文档记录和代码以及静态资源文件 效果对比 kiro 完成效果 对比 trae 参照官方文档资料使用 spec kit 完成效果。 代

将地图上的 poi 点位导出为 excel,并转换为 shp 文件

作者 闲云一鹤
2025年12月24日 17:25

前段时间接到一个需求,需要将地图上的 poi 点位数据导出为 shp 文件,下面是我的解决方案

点位数据为网上下载的公开数据,不触及涉密内容

1. 先将 poi 点位数据导出为带有经纬度的 Excel 文件(xlsx 格式)

cover.png

1.png

2. 用 Excel 打开 xlsx 文件,将 xlsx 转换为 csv 格式

2.png

操作步骤:文件 -> 另存为 -> 选择保存目录 -> 保存类型选择`CSV UTF-8(逗号分隔)(*.csv)` -> 点击保存

3. 用 QGIS 导入 csv 文件

3.png

4.png

操作步骤:打开QGIS -> 图层 -> 添加图层 -> 添加分隔文本图层 -> “文件名”输入框点击后面的三个点选中文件 -> “几何图形定义”下面的“点坐标”将 X字段选中经度,Y字段选中纬度 -> “几何图形CRS”选中“EPSG:4326 - WGS 84-> 点击添加

5.png

这里可以看到,点位数据已经成功添加进来了,并且查看点位字段也全部加载正确

4. 用 QGIS 将 csv 文件转换为 shp 文件

6.png

7.png

8.png

操作步骤:在导入的图层上面点击鼠标右键 -> 导出 -> 要素另存为 -> 选择要导出的文件夹并输入文件名 -> 保存类型选ESRI形状文件(*shp *.SHP) -> 点击ok按钮

9.png

现在可以看到,shp 文件已经转换成功!

Vue3 v-if与v-show:销毁还是隐藏,如何抉择?

作者 kknone
2025年12月24日 16:27

1. v-if 与 v-show 的基本概念

条件渲染是Vue3中控制界面显示的核心能力,而v-ifv-show是实现这一能力的两大核心指令。它们的本质差异,要从“**是否真正改变组件的存在 **”说起。

1.1 v-if:真正的“存在与否”

v-if是“破坏性条件渲染”——当条件为true时,它会创建组件实例并渲染到DOM中;当条件为false时,它会销毁 组件实例并从DOM中移除。换句话说,v-if控制的是“组件是否存在”。

举个例子:


<button @click="toggle">切换文本</button>
<div v-if="isShow">Hello, Vue3!</div>

isShowfalse时,你在浏览器DevTools里找不到这个div——它被完全销毁了。而且,组件的生命周期钩子(如onMounted/ onUnmounted)会随着条件切换触发:销毁时执行onUnmounted,重建时执行onMounted

1.2 v-show:只是“看得见看不见”

v-show是“非破坏性条件渲染”——无论条件真假,它都会先把组件渲染到DOM中,再通过修改CSS的display属性控制可见性。换句话说, v-show控制的是“组件是否可见”,但组件始终存在。

同样的例子,换成v-show


<button @click="toggle">切换文本</button>
<div v-show="isShow">Hello, Vue3!</div>

isShowfalse时,div依然在DOM中,只是多了style="display: none"。此时,组件实例没有被销毁,生命周期钩子也不会触发——它只是“被藏起来了”。

2. 原理拆解:为什么行为差异这么大?

理解原理是选择的关键,我们用“生活比喻”帮你快速记住:

  • v-if像“客房的家具”:客人来了(条件为真),你把家具搬出来(创建组件);客人走了(条件为假),你把家具收起来(销毁组件)。每次搬运都要花时间(切换成本高),但平时不占空间(初始化成本低)。
  • v-show像“客厅的电视”:不管你看不看(条件真假),电视都在那里(存在于DOM);你只是用遥控器(v-show )切换“显示/隐藏”(修改CSS)。切换动作很快(成本低),但始终占地方(初始化成本高)。

3. 性能对比:初始化 vs 切换成本

v-ifv-show的性能差异,本质是**“空间换时间”还是“时间换空间”**的选择:

3.1 初始化成本:v-if 更“省空间”

当初始条件为false时:

  • v-if:不渲染任何内容,DOM中无节点,初始化速度快
  • v-show:强制渲染组件,DOM中存在节点,初始化速度慢

比如,一个“仅管理员可见”的按钮,用v-if更合适——普通用户打开页面时,按钮不会被渲染,减少页面加载时间。

3.2 切换成本:v-show 更“省时间”

当条件需要频繁切换时:

  • v-if:每次切换都要销毁重建组件,涉及DOM操作和生命周期钩子,切换速度慢
  • v-show:仅修改CSS属性,无DOM重建,切换速度快

比如, tabs 切换、弹窗显示隐藏,用v-show更流畅——用户点击时不会有延迟。

4. 选择策略:到底该用谁?

结合原理和性能,我们总结了3条黄金法则

4.1 频繁切换?选v-show!

如果组件需要反复显示/隐藏(如 tabs、弹窗、折叠面板),优先用v-show。比如:

<!-- 频繁切换的弹窗,用v-show -->
<modal v-show="isModalOpen" @close="isModalOpen = false"></modal>

4.2 极少变化?选v-if!

如果条件几乎不会改变(如权限控制、初始化提示),优先用v-if。比如:

<!-- 仅管理员可见的按钮,用v-if -->
<button v-if="isAdmin" @click="deleteItem">删除</button>

4.3 要保留状态?选v-show!

如果组件包含需要保留的状态(如表单输入、播放器进度),必须用v-show——v-if会销毁组件,导致状态丢失。

举个直观的例子:


<template>
    <button @click="toggle">切换输入框</button>
    <!-- v-if:输入内容会重置 -->
    <div v-if="isShow">
        <input type="text" placeholder="v-if 输入框"/>
    </div>
    <!-- v-show:输入内容保留 -->
    <div v-show="isShow">
        <input type="text" placeholder="v-show 输入框"/>
    </div>
</template>

<script setup>
    import {ref} from 'vue'

    const isShow = ref(true)
    const toggle = () => isShow.value = !isShow.value
</script>
往期文章归档
免费好用的热门在线工具

试着输入内容后切换:v-if的输入框会清空(组件销毁),v-show的输入框内容不变(组件存在)。

5. 动手实践:看得到的差异

为了更直观,我们用生命周期钩子验证两者的区别:

  1. 创建子组件Child.vue

    <template><div>我是子组件</div></template>
    <script setup>
    import { onMounted, onUnmounted } from 'vue'
    onMounted(() => console.log('子组件挂载了!'))
    onUnmounted(() => console.log('子组件销毁了!'))
    </script>
    
  2. 父组件中切换:

    <template>
      <button @click="toggle">切换子组件</button>
      <!-- 用v-if时,切换会打印日志 -->
      <Child v-if="isShow" />
      <!-- 用v-show时,切换无日志 -->
      <!-- <Child v-show="isShow" /> -->
    </template>
    
    <script setup>
    import { ref } from 'vue'
    import Child from './Child.vue'
    const isShow = ref(true)
    const toggle = () => isShow.value = !isShow.value
    </script>
    

运行后点击按钮:

  • v-if:切换会打印“子组件销毁了!”和“子组件挂载了!”(组件生死轮回);
  • v-show:无日志(组件始终存在)。

6. 课后Quiz:巩固你的理解

问题:你在开发“用户设置”页面,其中“高级设置”面板可以点击“展开/收起”切换。面板包含多个输入框(如“个性签名”),需要保留用户输入。请问该用 v-if还是v-show?为什么?

答案解析
v-show。原因有二:

  1. 频繁切换:用户可能多次展开/收起,v-show切换成本更低;
  2. 状态保留:输入框需要保留内容,v-show不会销毁组件,状态不会丢失。

7. 常见报错与解决

使用v-if/v-show时,这些“坑”要避开:

问题1:v-show 不能和 v-else 一起用

报错v-else can only be used with v-if
原因v-elsev-if的配套指令,v-show是CSS控制,无法配合。
解决:用v-if代替v-show,或分开写v-show

<!-- 错误 -->
<div v-show="isShow">内容A</div>
<div v-else>内容B</div>

<!-- 正确:用v-if -->
<div v-if="isShow">内容A</div>
<div v-else>内容B</div>

<!-- 正确:分开写v-show -->
<div v-show="isShow">内容A</div>
<div v-show="!isShow">内容B</div>

问题2:v-if 和 v-for 一起用导致性能低

报错场景:同一个元素同时用v-ifv-for


<li v-for="item in list" v-if="item.isActive">{{ item.name }}</li>

原因:Vue3中v-for优先级高于v-if,会先循环所有元素,再逐个判断条件,重复计算导致性能差。
解决:用computed先过滤数组:


<template>
    <li v-for="item in activeItems" :key="item.id">{{ item.name }}</li>
</template>

<script setup>
    import {ref, computed} from 'vue'

    const list = ref([/* 数据 */])
    // 先过滤出active的item
    const activeItems = computed(() => list.value.filter(item => item.isActive))
</script>

问题3:v-show 对 template 无效

报错场景:用v-show控制<template>标签:


<template v-show="isShow">
    <div>内容</div>
</template>

原因<template>是Vue的虚拟标签,不会渲染成真实DOM,v-show无法修改其display属性。
解决:用真实DOM元素(如<div>)包裹,或用<template v-if>

<!-- 正确:用div包裹 -->
<div v-show="isShow">
    <div>内容</div>
</div>

<!-- 正确:用v-if -->
<template v-if="isShow">
    <div>内容</div>
</template>

8. 参考链接

参考链接:vuejs.org/guide/essen…

从零实现前端监控告警系统:SMTP + Node.js + 个人邮箱 完整免费方案

2025年12月24日 16:22

本文将详细介绍如何为前端监控平台设计并实现一套完整的邮件告警系统,包括架构设计、核心原理、代码实现和最佳实践。

一、为什么需要告警系统?

在前端监控平台中,我们通常会采集大量的错误和性能数据。但如果只是被动地等待开发者登录 Dashboard 查看,很多问题可能已经影响了大量用户。

告警系统的核心价值:

  • 🚨 实时感知:错误发生时第一时间通知相关人员
  • 🎯 精准触达:根据规则过滤,避免告警轰炸
  • 快速响应:缩短问题发现到修复的时间窗口

二、整体架构设计

┌─────────────────────────────────────────────────────────────────┐
│                        前端应用                                  │
│                    (SDK 错误采集)                                │
└─────────────────────┬───────────────────────────────────────────┘
                      │ HTTP POST /api/report
                      ▼
┌─────────────────────────────────────────────────────────────────┐
│                      Server 层                                   │
│  ┌─────────────┐   ┌─────────────┐   ┌─────────────┐           │
│  │  数据接收    │──▶│  错误聚合   │──▶│  告警检查   │           │
│  │  (report)   │   │ (fingerprint)│   │  (rules)   │           │
│  └─────────────┘   └─────────────┘   └──────┬──────┘           │
│                                              │                   │
│                                              ▼                   │
│                                     ┌─────────────┐             │
│                                     │  规则评估   │             │
│                                     │ (evaluate)  │             │
│                                     └──────┬──────┘             │
│                                              │ 触发              │
│                                              ▼                   │
│  ┌─────────────┐   ┌─────────────┐   ┌─────────────┐           │
│  │  告警历史   │◀──│  邮件发送   │◀──│  冷却检查   │           │
│  │  (history)  │   │   (SMTP)    │   │ (cooldown)  │           │
│  └─────────────┘   └─────────────┘   └─────────────┘           │
└─────────────────────────────────────────────────────────────────┘
                      │
                      ▼ SMTP
┌─────────────────────────────────────────────────────────────────┐
│                    邮件服务器                                    │
│              (QQ邮箱/163/企业邮箱)                               │
└─────────────────────────────────────────────────────────────────┘
                      │
                      ▼ 📧
                  开发者邮箱

三、核心模块设计

3.1 告警规则模型

告警规则是整个系统的核心,定义了"什么情况下触发告警":

interface AlertRule {
  id: number;
  dsn: string;           // 项目标识
  name: string;          // 规则名称
  type: AlertType;       // 告警类型
  enabled: boolean;      // 是否启用
  threshold?: number;    // 阈值
  timeWindow?: number;   // 时间窗口(分钟)
  recipients: string[];  // 收件人列表
  cooldown: number;      // 冷却时间(分钟)
}

type AlertType = 
  | 'new_error'        // 新错误首次出现
  | 'error_threshold'  // 错误累计次数超过阈值
  | 'error_spike';     // 时间窗口内错误激增

三种告警类型的适用场景:

类型 场景 示例
new_error 捕获未知错误 新上线功能出现 bug
error_threshold 监控已知问题 某接口错误超过 100 次
error_spike 检测异常波动 5 分钟内错误数突增

3.2 冷却机制

为了避免同一个错误短时间内重复告警(告警轰炸),我们引入了冷却机制:

// 内存缓存:记录最近告警时间
const alertCooldowns = new Map<string, number>();

function shouldTrigger(rule: AlertRule, fingerprint: string): boolean {
  const cooldownKey = `${rule.id}-${fingerprint}`;
  const lastAlert = alertCooldowns.get(cooldownKey);
  
  // 检查是否在冷却期内
  if (lastAlert && Date.now() - lastAlert < rule.cooldown * 60 * 1000) {
    return false; // 冷却中,不触发
  }
  
  return true;
}

// 触发告警后更新冷却时间
function updateCooldown(rule: AlertRule, fingerprint: string) {
  const cooldownKey = `${rule.id}-${fingerprint}`;
  alertCooldowns.set(cooldownKey, Date.now());
}

冷却机制的关键点:

  1. 按规则+错误指纹组合作为冷却 key,而不是全局冷却
  2. 使用内存 Map 存储,重启后冷却状态重置(可接受)
  3. 冷却时间可配置,建议默认 30 分钟

3.3 规则评估引擎

规则评估是告警系统的"大脑",决定是否触发告警:

async function evaluateRule(rule: AlertRule, errorData: ErrorData): Promise<boolean> {
  // 1. 先检查冷却
  if (!shouldTrigger(rule, errorData.fingerprint)) {
    return false;
  }

  // 2. 根据规则类型评估
  switch (rule.type) {
    case 'new_error':
      // 新错误:检查是否首次出现
      return errorData.isNew;

    case 'error_threshold':
      // 阈值:检查累计次数
      return errorData.count >= rule.threshold;

    case 'error_spike': {
      // 激增:查询时间窗口内的错误数
      const windowStart = Date.now() - rule.timeWindow * 60 * 1000;
      const recentCount = await getErrorCountSince(
        errorData.dsn, 
        errorData.fingerprint, 
        windowStart
      );
      return recentCount >= rule.threshold;
    }

    default:
      return false;
  }
}

四、邮件服务实现

4.1 Nodemailer 配置

使用 nodemailer 库发送邮件,支持主流 SMTP 服务:

import nodemailer, { Transporter } from 'nodemailer';

let transporter: Transporter | null = null;

function initEmailService(config: EmailConfig) {
  transporter = nodemailer.createTransport({
    host: config.host,      // smtp.qq.com
    port: config.port,      // 465
    secure: true,           // 使用 SSL
    auth: {
      user: config.user,    // 邮箱账号
      pass: config.pass     // 授权码(不是登录密码!)
    }
  });
}

常见 SMTP 配置:

服务商 Host Port 备注
QQ邮箱 smtp.qq.com 465 需开启 SMTP 服务,使用授权码
163邮箱 smtp.163.com 465 需开启 SMTP 服务
Gmail smtp.gmail.com 587 需开启两步验证,使用应用密码
企业微信 smtp.exmail.qq.com 465 企业邮箱

4.2 邮件模板设计

告警邮件需要清晰展示关键信息:

image.png

image.png邮件设计要点:

  1. 使用内联样式(邮件客户端不支持外部 CSS)
  2. 关键信息突出显示(错误消息、次数)
  3. 提供快捷操作入口(查看详情按钮)
  4. 移动端适配(响应式布局)

4.3 发送邮件

async function sendAlertEmail(data: AlertEmailData): Promise<boolean> {
  if (!transporter) {
    console.warn('[Email] Service not initialized');
    return false;
  }

  try {
    const info = await transporter.sendMail({
      from: '"Sentinel 监控" <monitor@example.com>',
      to: data.recipients.join(', '),
      subject: `🚨 [${data.ruleName}] ${data.errorMessage.slice(0, 50)}`,
      html: generateAlertEmailHtml(data)
    });
    
    console.log('[Email] Sent:', info.messageId);
    return true;
  } catch (error) {
    console.error('[Email] Failed:', error);
    return false;
  }
}

五、完整告警流程

5.1 错误上报时触发检查

在错误数据入库后,异步检查告警规则:

// routes/report.ts
async function saveErrorEvent(dsn: string, event: ErrorEvent) {
  const { fingerprint } = generateFingerprint(event);
  
  // 1. 检查是否新错误
  const existing = await db.query(
    'SELECT id, count FROM errors WHERE fingerprint = $1',
    [fingerprint]
  );
  const isNew = existing.rows.length === 0;
  const count = isNew ? 1 : existing.rows[0].count + 1;
  
  // 2. 保存/更新错误记录
  if (isNew) {
    await db.query('INSERT INTO errors ...', [...]);
  } else {
    await db.query('UPDATE errors SET count = $1 ...', [count]);
  }
  
  // 3. 异步检查告警(不阻塞响应)
  checkAndTriggerAlerts({
    dsn,
    type: event.type,
    message: event.message,
    fingerprint,
    url: event.url,
    isNew,
    count
  }).catch(err => console.error('[Alert] Check failed:', err));
}

5.2 告警检查主流程

async function checkAndTriggerAlerts(errorData: ErrorData) {
  // 1. 检查邮件服务是否可用
  if (!isEmailConfigured()) return;

  // 2. 获取该项目的所有启用规则
  const rules = await getAlertRules(errorData.dsn);
  const enabledRules = rules.filter(r => r.enabled);

  // 3. 逐个评估规则
  for (const rule of enabledRules) {
    const shouldTrigger = await evaluateRule(rule, errorData);
    
    if (shouldTrigger) {
      // 4. 触发告警
      await triggerAlert(rule, errorData);
    }
  }
}

async function triggerAlert(rule: AlertRule, errorData: ErrorData) {
  // 1. 发送邮件
  const emailSent = await sendAlertEmail({
    to: rule.recipients,
    subject: `🚨 [${rule.name}] ${errorData.message.slice(0, 50)}`,
    errorMessage: errorData.message,
    errorType: errorData.type,
    errorCount: errorData.count,
    url: errorData.url,
    timestamp: Date.now()
  });

  // 2. 记录告警历史
  await saveAlertHistory(rule.id, errorData, emailSent);

  // 3. 更新冷却时间
  updateCooldown(rule, errorData.fingerprint);
  
  console.log(`[Alert] Triggered: ${rule.name}, sent: ${emailSent}`);
}

六、数据库设计

6.1 告警规则表

CREATE TABLE alert_rules (
  id SERIAL PRIMARY KEY,
  dsn TEXT NOT NULL,                    -- 项目标识
  name VARCHAR(100) NOT NULL,           -- 规则名称
  type VARCHAR(20) NOT NULL,            -- 告警类型
  enabled BOOLEAN DEFAULT true,         -- 是否启用
  threshold INTEGER,                    -- 阈值
  time_window INTEGER DEFAULT 60,       -- 时间窗口(分钟)
  recipients TEXT[] NOT NULL,           -- 收件人数组
  cooldown INTEGER DEFAULT 30,          -- 冷却时间(分钟)
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_alert_rules_dsn ON alert_rules(dsn);

6.2 告警历史表

CREATE TABLE alert_history (
  id SERIAL PRIMARY KEY,
  rule_id INTEGER REFERENCES alert_rules(id),
  dsn TEXT NOT NULL,
  fingerprint TEXT,                     -- 错误指纹
  error_message TEXT,                   -- 错误消息
  triggered_at TIMESTAMP DEFAULT NOW(), -- 触发时间
  email_sent BOOLEAN DEFAULT false      -- 邮件是否发送成功
);

CREATE INDEX idx_alert_history_dsn ON alert_history(dsn, triggered_at);

七、API 设计

7.1 告警规则 CRUD

// 获取规则列表
GET /api/alerts/rules?dsn=demo-app

// 创建规则
POST /api/alerts/rules
{
  "dsn": "demo-app",
  "name": "生产环境错误告警",
  "type": "new_error",
  "recipients": ["dev@example.com"],
  "cooldown": 30
}

// 更新规则
PATCH /api/alerts/rules/:id
{
  "enabled": false,
  "threshold": 50
}

// 删除规则
DELETE /api/alerts/rules/:id

7.2 告警历史查询

// 获取告警历史
GET /api/alerts/history?dsn=demo-app&limit=50

// 响应
{
  "history": [
    {
      "id": 1,
      "ruleId": 1,
      "errorMessage": "Cannot read property 'x' of undefined",
      "triggeredAt": "2024-01-15T10:30:00Z",
      "emailSent": true
    }
  ]
}

7.3 邮件服务状态

// 检查邮件服务状态
GET /api/alerts/email-status
// { "configured": true, "connected": true }

// 发送测试邮件
POST /api/alerts/test-email
{ "email": "test@example.com" }

八、最佳实践

8.1 告警规则配置建议

// 推荐的规则组合
const recommendedRules = [
  {
    name: '新错误告警',
    type: 'new_error',
    cooldown: 60,        // 1小时内同一错误不重复告警
    recipients: ['oncall@team.com']
  },
  {
    name: '错误激增告警',
    type: 'error_spike',
    threshold: 100,      // 5分钟内超过100次
    timeWindow: 5,
    cooldown: 30,
    recipients: ['oncall@team.com', 'manager@team.com']
  },
  {
    name: '关键错误阈值',
    type: 'error_threshold',
    threshold: 1000,     // 累计超过1000次
    cooldown: 120,       // 2小时冷却
    recipients: ['dev@team.com']
  }
];

8.2 避免告警疲劳

  1. 合理设置冷却时间:避免同一问题反复告警
  2. 分级告警:不同严重程度发送给不同人员
  3. 聚合告警:相似错误合并为一条告警
  4. 静默时段:非工作时间降低告警频率

8.3 邮件发送优化

// 使用队列异步发送,避免阻塞主流程
import { Queue } from 'bull';

const emailQueue = new Queue('email-alerts');

emailQueue.process(async (job) => {
  await sendAlertEmail(job.data);
});

// 触发告警时加入队列
async function triggerAlert(rule, errorData) {
  await emailQueue.add({
    to: rule.recipients,
    subject: `🚨 ${errorData.message}`,
    // ...
  });
}

九、扩展方向

当前实现了邮件告警,后续可以扩展:

  1. 多渠道通知

    • 钉钉/飞书 Webhook
    • 企业微信机器人
    • Slack 集成
    • 短信通知(严重告警)
  2. 智能告警

    • 基于历史数据的异常检测
    • 告警收敛和去重
    • 根因分析关联
  3. 告警升级

    • 未处理告警自动升级
    • 值班表集成
    • 告警认领机制

十、总结

本文介绍了前端监控告警系统的完整实现方案:

  • 架构设计:错误上报 → 规则评估 → 冷却检查 → 邮件发送
  • 核心机制:三种告警类型 + 冷却防抖 + 异步处理
  • 技术选型:Node.js + Nodemailer + PostgreSQL

告警系统是监控平台的"最后一公里",让被动查看变为主动通知,大大提升了问题响应效率。


完整代码已开源:GitHub - Sentinel 前端监控平台

如果觉得有帮助,欢迎 Star ⭐️

🎯 从零搭建一个 React Todo 应用:父子通信、状态管理与本地持久化全解析!

2025年12月24日 16:11

“写 Todo 是程序员的成人礼。”

如果你刚刚入坑 React,或者想巩固组件通信、状态提升、本地存储等核心概念,那么恭喜你!这篇文章将带你手把手打造一个功能完整、结构清晰、代码优雅的 React Todo 应用,并深入浅出地解释背后的原理。

更重要的是——我们不用 Redux、不用 Context、不用任何花里胡哨的库,只用 React 原生 Hooks + 父子通信,就能写出可维护、可扩展的代码!


🧠 为什么 Todo 应用值得认真对待?

别小看这个“加任务、删任务、标记完成”的小玩意儿。它完美涵盖了现代前端开发的三大核心问题:

  1. 状态管理(谁持有数据?谁修改数据?)
  2. 组件通信(父子怎么传?兄弟怎么聊?)
  3. 副作用处理(比如自动保存到 localStorage)

而 React 的哲学是:状态提升 + 单向数据流。听起来高大上?其实很简单——让父组件当“管家”,子组件只负责“汇报”和“展示”


🏗️ 项目结构预览

我们的应用由三个子组件构成:

  • TodoInput:输入新任务
  • TodoList:展示并操作任务列表
  • TodoStats:显示统计信息 & 清除已完成

它们都共享同一个状态:todos[]。这个数组由父组件 App 统一管理,并通过 props 传递给子组件。

✨ 这就是“状态提升”(Lifting State Up)的经典实践!


🔌 父子通信:React 的“单向数据流”哲学

👨‍👧 父 → 子:通过 props 传递数据

<TodoList 
  todos={todos} 
  onDelete={deleteTodo}
  onToggle={toggleTodo}
/>

父组件把 todos 数组和几个修改函数作为 props 传给子组件。子组件只能读,不能改——就像孩子只能看菜单,不能自己进厨房炒菜。

👧‍→👨 子 → 父:通过回调函数“打报告”

子组件想修改数据?必须调用父组件传来的函数:

// 在 TodoInput 中
onAdd(inputValue); // 相当于:“爸,我想加个任务!”

// 在 TodoList 中
onToggle(todo.id); // “爸,这个任务我搞定了!”

这种模式确保了数据流向清晰、可预测,避免了“状态混乱”的噩梦。

💡 小贴士:React 不支持 Vue 那样的 v-model 双向绑定,因为它认为“显式优于隐式”。虽然多写两行代码,但逻辑更透明!


🧩 兄弟组件如何“隔空对话”?

TodoInputTodoList 是兄弟,它们之间没有直接通信!所有交互都通过共同的父组件 App 中转:

  1. TodoInput 调用 onAdd → 父组件更新 todos
  2. 父组件把新 todos 传给 TodoList → 列表自动刷新

这就是所谓的 “间接通信” ——看似绕路,实则解耦。兄弟组件互不依赖,未来拆分或替换都超轻松!


💾 自动保存到 localStorage:useEffect 的妙用

用户辛辛苦苦加了一堆任务,结果一刷新全没了?那可不行!

我们用 useEffect 监听 todos 变化,自动存到本地:

useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

同时,初始化时从 localStorage 读取:

const [todos, setTodos] = useState(() => {
  const saved = localStorage.getItem('todos');
  return saved ? JSON.parse(saved) : [];
});

🎉 用户体验瞬间拉满:关掉浏览器再打开,任务还在!妈妈再也不用担心我丢三落四了~


🎨 样式方案:Stylus + Vite,简洁又高效

我们用 Stylus 写样式(缩进语法,少写大括号),配合 Vite 极速构建。.styl 文件清爽易读:

.todo-app
  max-width: 600px
  margin: 0 auto
  padding: 20px
  
  .completed
    text-decoration: line-through
    color: #888

Vite 的 HMR(热更新)快如闪电,改一行样式,浏览器秒级响应——开发幸福感爆棚!


🧪 完整代码亮点回顾

  • 状态集中管理:所有 todos 操作在 App 中定义
  • 函数式更新setTodos([...todos, newTodo]) 避免闭包陷阱
  • 条件渲染completed > 0 && <button> 避免无效按钮
  • 语义化 JSX<label> 包裹 checkbox,提升可访问性
  • 性能友好:无多余状态,无复杂计算

🤔 思考:为什么不用 Context 或 Zustand?

对于小型应用(如 Todo),过度设计反而增加复杂度。Context 适合跨多层组件共享状态,Zustand 适合大型状态树。而我们的场景——三个组件 + 一个状态数组,用 props 足矣!

🚀 记住:简单即强大。能用 props 解决的问题,就别急着上状态管理库!


🎁 结语:你的第一个 React 应用,也可以很优雅

通过这个 Todo 应用,你不仅学会了组件通信,更理解了 React 的核心思想:状态驱动视图、单向数据流、组合优于继承

下次面试官问:“React 组件怎么通信?” 你可以微微一笑,掏出这个项目说:

“看,我的 Todo,麻雀虽小,五脏俱全。”

❌
❌