阅读视图

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

5大核心分析维度+3种可视化方案:脑肿瘤大数据分析系统全解析 毕业设计 选题推荐 毕设选题 数据分析 机器学习

脑肿瘤数据可视化分系统-简介

本系统是一个基于Hadoop+Spark的大数据分析平台,专注于脑肿瘤医疗数据的可视化研究。系统后端采用Python语言,结合Django框架构建服务接口,并利用Spark进行大规模数据的高效处理与计算。原始脑肿瘤数据存储于Hadoop分布式文件系统(HDFS)中,通过Spark SQL对数据进行清洗、转换和多维度聚合分析。分析功能涵盖患者人口学特征、肿瘤临床特征、治疗方案与预后效果、临床症状关联性以及高风险因素探索等五大核心模块。处理后的结果经由Django API传递至前端,前端则运用Vue框架结合ElementUI组件库与Echarts图表库,将复杂的数据关系转化为直观的交互式图表,如性别年龄分布、肿瘤位置与恶性程度关联、不同治疗方案生存率对比等,为医疗研究者和临床医生提供一个全面、高效的数据洞察工具。

脑肿瘤数据可视化分系统-技术

开发语言:Python或Java 大数据框架:Hadoop+Spark(本次没用Hive,支持定制) 后端框架:Django+Spring Boot(Spring+SpringMVC+Mybatis) 前端:Vue+ElementUI+Echarts+HTML+CSS+JavaScript+jQuery 详细技术点:Hadoop、HDFS、Spark、Spark SQL、Pandas、NumPy 数据库:MySQL

脑肿瘤数据可视化分系统-背景

选题背景 随着医疗信息化进程的加快,医院积累了海量的脑肿瘤患者诊疗数据,这些数据包含了从患者基本信息到复杂治疗方案的多个维度。脑肿瘤本身作为一种复杂的疾病,其成因、发展和治疗效果受到众多因素交织影响。面对如此庞大且关系错综复杂的数据集,传统的统计分析工具往往显得力不从心,难以快速、全面地揭示隐藏在数据背后的规律。如何有效利用这些宝贵的数据资产,从中发现有价值的临床洞见,辅助医生进行更精准的诊断和治疗决策,成为了当前医疗领域面临的一个实际问题。因此,构建一个能够处理和分析这类复杂数据的系统显得尤为必要。

选题意义 本课题的意义在于将前沿的大数据技术应用于具体的医疗数据分析场景中,具有很强的实践价值。从技术层面看,它完整地实践了从数据存储、分布式计算到前端可视化的全流程,巩固了对Hadoop和Spark生态的理解与应用能力。从应用角度看,系统通过多维度的交互式图表,将原本枯燥的脑肿瘤数据变得直观易懂,能够帮助医学专业的学生或初级研究人员快速把握数据特征,发现一些潜在的临床关联模式,比如特定年龄段的高发肿瘤类型或不同治疗方案的疗效对比。虽然作为一个毕业设计,其分析深度和模型精度有限,但它为探索医疗数据的价值提供了一个可行的方法和思路,展示了大数据技术在精准医疗领域的应用潜力。

脑肿瘤数据可视化分系统-视频展示

[video(video-53oW3KNj-1768628790189)(type-csdn)(url-live.csdn.net/v/embed/510…)]

脑肿瘤数据可视化分系统-图片展示

在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述

脑肿瘤数据可视化分系统-代码展示

from pyspark.sql import SparkSession, functions as F
from pyspark.sql.types import IntegerType
spark = SparkSession.builder.appName("BrainTumorAnalysis").getOrCreate()
df = spark.read.csv("hdfs://path/to/brain_tumor_data.csv", header=True, inferSchema=True)
def analyze_age_gender_distribution():
    age_group_df = df.withColumn("Age_Group", F.when((df.Age < 18), "少年").when((df.Age >= 18) & (df.Age < 40), "青年").when((df.Age >= 40) & (df.Age < 60), "中年").otherwise("老年"))
    result_df = age_group_df.groupBy("Age_Group", "Gender").count().orderBy("Age_Group", "Gender")
    result_df.show()
    return result_df
def analyze_treatment_survival():
    treatment_df = df.withColumn("Treatment_Combination", F.concat_ws("+", F.when(df.Surgery_Performed == "Yes", "手术"), F.when(df.Radiation_Treatment == "Yes", "放疗"), F.when(df.Chemotherapy == "Yes", "化疗")))
    survival_df = treatment_df.groupBy("Treatment_Combination").agg(F.avg("Survival_Rate").alias("Average_Survival_Rate"), F.count("*").alias("Patient_Count")).orderBy(F.desc("Average_Survival_Rate"))
    survival_df.show()
    return survival_df
def analyze_correlation():
    correlation_df = df.select("Age", "Tumor_Size", "Survival_Rate", "Tumor_Growth_Rate").na.drop()
    age_size_corr = correlation_df.stat.corr("Age", "Tumor_Size")
    age_survival_corr = correlation_df.stat.corr("Age", "Survival_Rate")
    size_survival_corr = correlation_df.stat.corr("Tumor_Size", "Survival_Rate")
    growth_survival_corr = correlation_df.stat.corr("Tumor_Growth_Rate", "Survival_Rate")
    print(f"年龄与肿瘤尺寸的相关系数: {age_size_corr}")
    print(f"年龄与生存率的相关系数: {age_survival_corr}")
    print(f"肿瘤尺寸与生存率的相关系数: {size_survival_corr}")
    print(f"肿瘤生长速率与生存率的相关系数: {growth_survival_corr}")
    return {"age_size": age_size_corr, "age_survival": age_survival_corr, "size_survival": size_survival_corr, "growth_survival": growth_survival_corr}

脑肿瘤数据可视化分系统-结语

这个项目完整走通了大数据分析流程,从Hadoop存储到Spark计算,再到前端可视化,技术栈很扎实。希望能给正在做毕设的同学一点启发。如果觉得有帮助,别忘了点赞收藏,你的支持是我更新的最大动力!

刚肝完这个基于Spark的脑肿瘤分析毕设,感觉头发又掉了不少😂。数据清洗和特征工程真的太磨人了,但最后看到Echarts图表出来的那一刻,值了!大家选题都定了吗?评论区聊聊,互相避坑啊!

ArcGIS Pro 实现影像波段合成

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

前言

通常,我们下载的卫星影像数据每个波段都存在一个单独的波段中,但是在生产实践中,我们往往需要由各个波段组成的完整数据集。所以,这个时候就需要进行波段合成操作。

本节主要讲解如何在ArcGIS Pro中实现TIFF影像波段合成。

1. 软件环境

本文使用以下软件环境,仅供参考。

时间:2026 年

操作软件:ArcGIS Pro 3.5

操作系统:windows 11

2. 下载卫星影像数据

俗话说巧妇难为无米之炊,数据就是软件的基石,没有数据,再美好的设想都是空中楼阁。因此,第一步需要下载遥感影像数据。

但是,影像数据在哪里下载呢?别着急,本文都给你整理好了。

数据下载可参考文章:GIS 影像数据源介绍


如下,这是我在【地理空间数据云】平台下载的landsat8遥感影像。

3. ArcGIS Pro 软件安装

要想使用ArcGIS Pro实现影像波段合成,那你得安装好ArcGIS Pro软件。

但是,软件安装说明不在本文的教程之内,就不进行介绍了,请未安装的同学自行解决。

4. 波段合成

好了,经过上面一堆废话,下面正式进入主题,进行实操。

如果有过ArcGIS版本软件基础的同学,可以很快完成,因为ArcGIS ProArcGIS的工具设置大体相同。

我们首先需要找到数据处理工具箱。点击菜单栏分析按钮Analysis,然后再点击工具Tools

或者点击软件搜索框中,其中会出现推荐的地理处理工具箱。

打开工具之后,点击数据数据处理工具Data Management Tools

然后依次点击栅格Raster、栅格处理Raster Processing、波段合成Composite Bands即可。

如果你觉得上述操作路径太长,或者你熟悉操作工具名称的话,可以直接在页面顶部搜索框输入工具名称Composite Bands进行检索。又或者在地理处理搜索框输入工具名称Composite Bands进行检索。

打开波段合成工具,先在输入栅格中选择需要进行合成的波段数据,然后选择输出位置,最后点击运行。

如下为波段4、波段3、波段2合成的彩色效果图。

如下为波段5、波段4、波段3合成的彩色效果图。

可在属性中查看源数据信息,其中三个波段显示如下。

OpenLayers示例数据下载,请在公众号后台回复:ol数据

全国信息化工程师-GIS 应用水平考试资料,请在公众号后台回复:GIS考试

GIS之路 公众号已经接入了智能 助手,可以在对话框进行提问,也可以直接搜索历史文章进行查看。

都看到这了,不要忘记点赞、收藏 + 关注

本号不定时更新有关 GIS开发 相关内容,欢迎关注 


    

GeoTools 开发合集(全)

OpenLayers 开发合集

GDAL 实现矢量数据转换处理(全)

GDAL 实现投影转换

自然资源部党组关于苗泽等4名同志职务任免的通知

国产版的Google Earth,吉林一号卫星App“共生地球”来了

2026年全国自然资源工作会议召开

日本欲打造“本土版”星链系统

吉林一号国内首张高分辨率彩色夜光卫星影像发布

2025 年度信创领军企业名单出炉!

2026 年 Node.js + TS 开发:别再纠结 nodemon 了,聊聊热编译的最优解

在开发 Node.js 服务端时,“修改代码 -> 自动生效”的开发体验(即热编译/热更新)是影响效率的关键。随着 Node.js 23+  原生支持 TS 以及 Vite 5 的普及,我们的工具链已经发生了巨大的更迭。

今天我们深度拆解三种主流的 Node.js TS 开发实现方式,帮你选出最适合 2026 年架构的方案。


一、 方案对比大盘点

方案 核心原理 优点 缺点 适用场景
tsx (Watch Mode) 基于 esbuild 的极速重启 零配置、性能强、生态位替代 nodemon 每次修改重启整个进程,状态丢失 小型服务、工具脚本
vite-node 基于 Vite 的模块加载器 完美继承 Vite 配置、支持模块级 HMR 配置相对复杂,需手动处理 HMR 逻辑 中大型 Vite 全栈项目
Node.js 原生 Node 23+ Type Stripping 无需第三方依赖,官方标准 需高版本 Node,功能相对单一 追求极简、前瞻性实验

二、 方案详解

  1. 现代替代者:tsx —— 告别 nodemon + ts-node

过去我们常用 nodemon --exec ts-node,但在 ESM 时代,这套组合经常报 ERR_UNKNOWN_FILE_EXTENSION 错误。

tsx 内部集成了 esbuild,它是目前 Node 18+ 环境下最稳健的方案。

  • 实现热编译:

    bash

    npx tsx --watch src/index.ts
    

    请谨慎使用此类代码。

  • 为什么选它:  它不需要额外的加载器配置(--loader),且 watch 模式非常智能,重启速度在毫秒级。

  1. 开发者体验天花板:vite-node —— 真正的 HMR

如果你已经在项目中使用 Vite 5,那么 vite-node 是不二之选。它不仅是“重启”,而是“热替换”。

  • 核心优势:

    • 共享配置:直接复用 vite.config.ts 中的 alias 和插件。
    • 按需编译:只编译当前运行到的模块,项目越大优势越明显。
  • 实现热更新(不重启进程):

    typescript

    // src/index.ts
    import { app } from './app';
    let server = app.listen(3000);
    
    if (import.meta.hot) {
      import.meta.hot.accept('./app', (newModule) => {
        server.close(); // 优雅关闭旧服务
        server = newModule.app.listen(3000); // 启动新逻辑,DB连接可复用
      });
    }
    

    请谨慎使用此类代码。

  1. 官方正统:Node.js 原生支持

如果你能使用 Node.js 23.6+ ,那么可以摆脱所有构建工具。

  • 运行:  node --watch src/index.ts
  • 点评:  这是未来的趋势,但在 2026 年,由于生产环境往往还停留在 Node 18/20 LTS,该方案目前更多用于本地轻量级开发。

三、 避坑指南:Vite 5 打包 Node 服务的报错

在实现热编译的过程中,如果你尝试用 Vite 打包 Node 服务,可能会遇到:

Invalid value for option "preserveEntrySignatures" - setting this option to false is not supported for "output.preserveModules"

原因:  当你开启 preserveModules: true 想保持源码目录结构输出时,Rollup 无法在“强制保留模块”的同时又“摇树优化(Tree Shaking)”掉入口导出。

修复方案:
在 vite.config.ts 中明确设置:

typescript

build: {
  rollupOptions: {
    preserveEntrySignatures: 'exports-only', // 显式声明保留导出
    output: {
      preserveModules: true
    }
  }
}

请谨慎使用此类代码。


四、 总结:我该选哪个?

  1. 如果你只想快速写个接口,不想折腾配置:请直接使用 tsx。它是 2026 年 nodemon 的完美继承者。
  2. 如果你在做复杂全栈项目,或者有大量的路径别名:请使用 vite-node。它能让你在 Node 端获得跟前端 React/Vue 编写时一样丝滑的 HMR 体验。
  3. 如果是为了部署生产环境:无论开发环境用什么,生产环境请务必通过 vite build 产出纯净的 JS,并使用 node dist/index.js 运行。

使用 LangChain.js 在node端 连接glm大模型示例

使用 LangChain 在后端连接大模型:实践指南 🚀

本文以实战项目代码为例,包含完整后端接入、流式(SSE)实现、前端接收示例与调试方法,读者可直接复制运行。


介绍 ✨

随着大模型在各类应用中的普及,后端如何稳健地接入并把模型能力以 API/流式方式对外提供,成为常见需求。本文基于 LangChain(JS)演示如何在 Node.js 后端(Koa)中:

  • 初始化并调用大模型(示例使用智谱 GLM 的接入方式)
  • 支持普通请求与流式(Server-Sent Events,SSE)响应
  • 在前端用 fetch 读取流并实现打字机效果

适用人群:熟悉 JS/TS、Node.js、前端基本知识,想把模型能力放到后端并对外提供 API 的工程师。


一、准备与依赖 🧩

环境:Node.js 16+。

安装依赖(Koa 示例):

npm install koa koa-router koa-bodyparser @koa/cors dotenv
npm install @langchain/openai @langchain/core

在项目根创建 .env

ZHIPU_API_KEY=你的_api_key_here
PORT=3001

提示:不同模型提供方的 baseURL 与认证字段会不同,请根据提供方文档调整。


二、后端:服务封装(chatService)🔧

把模型调用封装到服务层,提供普通调用与流式调用接口:

// backend/src/services/chatService.js
import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage, SystemMessage } from "@langchain/core/messages";

const chatService = {
  llm: null,

  init() {
    if (!this.llm) {
      this.llm = new ChatOpenAI({
        openAIApiKey: process.env.ZHIPU_API_KEY,
        modelName: "glm-4.5-flash",
        temperature: 0.7,
        configuration: { baseURL: "https://open.bigmodel.cn/api/paas/v4/" },
      });
    }
  },

  async sendMessage(message, conversationHistory = []) {
    this.init();
    const messages = [
      new SystemMessage("你是一个有帮助的 AI 助手,使用中文回答问题。"),
      ...conversationHistory.map((msg) =>
        msg.role === "user"
          ? new HumanMessage(msg.content)
          : new SystemMessage(msg.content),
      ),
      new HumanMessage(message),
    ];

    try {
      const response = await this.llm.invoke(messages);
      return {
        success: true,
        content: response.content,
        usage: response.usage_metadata,
      };
    } catch (error) {
      console.error("聊天服务错误:", error);
      return { success: false, error: error.message };
    }
  },

  async sendMessageStream(message, conversationHistory = []) {
    this.init();
    const messages = [
      new SystemMessage("你是一个有帮助的 AI 助手,使用中文回答问题。"),
      ...conversationHistory.map((msg) =>
        msg.role === "user"
          ? new HumanMessage(msg.content)
          : new SystemMessage(msg.content),
      ),
      new HumanMessage(message),
    ];

    try {
      // 假设 llm.stream 返回异步迭代器,逐 chunk 返回 { content }
      const stream = await this.llm.stream(messages);
      return { success: true, stream };
    } catch (error) {
      console.error("流式聊天服务错误:", error);
      return { success: false, error: error.message };
    }
  },
};

export default chatService;

说明:实际 SDK 接口名(如 invokestream)请依据你所用的 LangChain / provider 版本调整。


三、控制器:普通与 SSE 流式(Koa)🌊

SSE 要点:需要直接写原生 res,并设置 ctx.respond = false,防止 Koa 在中间件链结束时覆盖响应或返回 404。

// backend/src/controllers/chatController.js
import chatService from "../services/chatService.js";

const chatController = {
  async sendMessage(ctx) {
    try {
      const { message, conversationHistory = [] } = ctx.request.body;
      if (!message) {
        ctx.status = 400;
        ctx.body = { success: false, error: "消息内容不能为空" };
        return;
      }
      const result = await chatService.sendMessage(
        message,
        conversationHistory,
      );
      if (result.success) ctx.body = result;
      else {
        ctx.status = 500;
        ctx.body = result;
      }
    } catch (error) {
      ctx.status = 500;
      ctx.body = { success: false, error: "服务器内部错误" };
    }
  },

  async sendMessageStream(ctx) {
    try {
      const { message, conversationHistory = [] } = ctx.request.body;
      if (!message) {
        ctx.status = 400;
        ctx.body = { success: false, error: "消息内容不能为空" };
        return;
      }

      const result = await chatService.sendMessageStream(
        message,
        conversationHistory,
      );
      if (!result.success) {
        ctx.status = 500;
        ctx.body = result;
        return;
      }

      ctx.set("Content-Type", "text/event-stream");
      ctx.set("Cache-Control", "no-cache");
      ctx.set("Connection", "keep-alive");
      ctx.status = 200;
      // 关键:让我们直接操作 Node 原生 res
      ctx.respond = false;

      for await (const chunk of result.stream) {
        const content = chunk.content;
        if (content) ctx.res.write(`data: ${JSON.stringify({ content })}\n\n`);
      }

      ctx.res.write("data: [DONE]\n\n");
      ctx.res.end();
    } catch (error) {
      console.error("流式控制器错误:", error);
      ctx.status = 500;
      ctx.body = { success: false, error: "服务器内部错误" };
    }
  },
};

export default chatController;

四、路由与启动 🌐

// backend/src/routes/index.js
import Router from "koa-router";
import chatController from "../controllers/chatController.js";
const router = new Router({ prefix: "/api" });
router.post("/chat", chatController.sendMessage);
router.post("/chat/stream", chatController.sendMessageStream);
export default router;

// backend/src/app.js
import dotenv from "dotenv";
import Koa from "koa";
import bodyParser from "koa-bodyparser";
import cors from "@koa/cors";
import router from "./routes/index.js";

dotenv.config();
const app = new Koa();
app.use(cors({ origin: "*", credentials: true }));
app.use(bodyParser());
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(process.env.PORT || 3001, () => console.log("Server running"));

五、前端:接收 SSE 流并实现“打字机”效果 ⌨️

前端用 fetch + ReadableStream 读取 SSE 后端发送的 chunk(格式为 data: {...}\n\n)。下面给出简洁示例:

// frontend/src/services/chatService.ts (核心片段)
export const sendMessageStream = async (
  message,
  conversationHistory,
  onChunk,
  onComplete,
  onError,
) => {
  try {
    const response = await fetch("/api/chat/stream", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message, conversationHistory }),
    });

    if (!response.ok) throw new Error("网络请求失败");
    const reader = response.body.getReader();
    const decoder = new TextDecoder();

    let buffer = "";
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      const chunk = decoder.decode(value);
      // 简单按行解析 SSE data: 行
      const lines = chunk.split("\n");
      for (const line of lines) {
        if (line.startsWith("data: ")) {
          const data = line.slice(6);
          if (data === "[DONE]") {
            onComplete();
            return;
          }
          try {
            const parsed = JSON.parse(data);
            if (parsed.content) onChunk(parsed.content);
          } catch (e) {}
        }
      }
    }
  } catch (err) {
    onError(err.message || "流式请求失败");
  }
};

打字机效果思路(前端)📌:

  • 后端 chunk 通常是按小段返回,前端把每个 chunk 追加到 buffer
  • 用一个定时器以固定速度(如 20–40ms/字符)把 buffer 的字符逐个移动到展示内容,使文本逐字出现。
  • onComplete 时快速显示剩余字符并停止定时器。

你可以参考项目中 App.tsx 的实现(已实现逐 chunk 追加与打字机渲染逻辑)。


App.tsx

import React, { useState } from "react";
import MessageList from "./components/MessageList";
import ChatInput from "./components/ChatInput";
import { chatService, Message } from "./services/chatService";
import "./App.css";

const App: React.FC = () => {
  const [messages, setMessages] = useState<Message[]>([]);
  const [isStreaming, setIsStreaming] = useState(false);

  const handleSendMessage = async (message: string) => {
    const userMessage: Message = { role: "user", content: message };
    setMessages((prev) => [...prev, userMessage]);
    setIsStreaming(true);

    let assistantContent = "";
    const conversationHistory = messages;

    await chatService.sendMessageStream(
      message,
      conversationHistory,
      (chunk: string) => {
        assistantContent += chunk;
        setMessages((prev) => {
          const newMessages = [...prev];
          const lastMessage = newMessages[newMessages.length - 1];
          if (lastMessage && lastMessage.role === "assistant") {
            lastMessage.content = assistantContent;
          } else {
            newMessages.push({ role: "assistant", content: assistantContent });
          }
          return newMessages;
        });
      },
      () => {
        setIsStreaming(false);
      },
      (error: string) => {
        console.error("流式响应错误:", error);
        setMessages((prev) => [
          ...prev,
          { role: "assistant", content: "抱歉,发生了错误,请稍后重试。" },
        ]);
        setIsStreaming(false);
      },
    );
  };

  return (
    <div className="app">
      <header className="app-header">
        <h1>🤖 LangChain + 智谱 GLM</h1>
        <p>AI 聊天助手</p>
      </header>
      <main className="app-main">
        <MessageList messages={messages} isStreaming={isStreaming} />
        <ChatInput onSendMessage={handleSendMessage} disabled={isStreaming} />
      </main>
    </div>
  );
};

export default App;

MessageList

import React, { useRef, useEffect } from "react";
import { Message } from "../services/chatService";

interface MessageListProps {
  messages: Message[];
  isStreaming: boolean;
}

const MessageList: React.FC<MessageListProps> = ({ messages, isStreaming }) => {
  const messagesEndRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages, isStreaming]);

  return (
    <div className="message-list">
      {messages.length === 0 ? (
        <div className="empty-state">
          <p>👋 欢迎使用 LangChain + 智谱 GLM 聊天助手!</p>
          <p>开始提问吧,我会尽力帮助你 💬</p>
        </div>
      ) : (
        messages.map((msg, index) => (
          <div key={index} className={`message ${msg.role}`}>
            <div className="message-avatar">
              {msg.role === "user" ? "👤" : "🤖"}
            </div>
            <div className="message-content">
              <div className="message-role">
                {msg.role === "user" ? "用户" : "AI 助手"}
              </div>
              <div className="message-text">{msg.content}</div>
            </div>
          </div>
        ))
      )}
      {isStreaming && (
        <div className="message assistant">
          <div className="message-avatar">🤖</div>
          <div className="message-content">
            <div className="message-role">AI 助手</div>
            <div className="message-text streaming">
              <span className="typing-indicator">...</span>
            </div>
          </div>
        </div>
      )}
      <div ref={messagesEndRef} />
    </div>
  );
};

export default MessageList;

六、调试建议与常见坑 ⚠️

  • 404:确认前端请求路径 /api/chat/stream 与路由前缀一致;开发时若使用 Vite,请在 vite.config.ts 配置 proxy 到后端端口。
  • SSE 返回 404/空响应:确认控制器里 ctx.respond = false 已设置,并且在设置 header 后立即开始 ctx.res.write
  • headers already sent:不要在写入原生 res 后再次设置 ctx.bodyctx.status
  • CORS:若跨域,确保后端 CORS 配置允许 Content-TypeAuthorization 等必要 header。

七、快速上手测试命令 🧪

启动后端:

# 在 backend 目录下
node src/app.js
# 或使用 nodemon
npx nodemon src/app.js

用 curl 测试流式(查看快速返回流):

curl -N -H "Content-Type: application/json" -X POST http://localhost:3001/api/chat/stream -d '{"message":"你好","conversationHistory":[]} '

你应能看到类似:

data: {"content":"你"}
data: {"content":"好"}
data: [DONE]

推荐一个超好用的全栈通用瀑布流布局库 universal-waterfall-layout

在前端开发中,瀑布流布局(Masonry Layout)一直是一个让人又爱又恨的需求。爱它是因为它能极大提升图片类应用(如 Pinterest、小红书)的视觉体验和空间利用率;恨它是因为实现起来坑点满满——图片高度不固定、窗口缩放重排、框架兼容性问题等等。

今天给大家推荐一个最近发现的宝藏开源库:universal-waterfall-layout

正如其名,不仅仅是给 Vue 用,也不仅仅是给 React 用,它是一个**通用(Universal)**的解决方案。

为什么选择它?

现在的市面上的瀑布流插件往往绑定特定框架,或者包体积过大。而 universal-waterfall-layout 有几个非常击中痛点的特性:

1. 🚀 全栈通用,一次学习

无论你是 Vue 3 爱好者,还是 React 拥趸,甚至是 原生 JS 的死忠粉,这个库都能无缝支持。它的核心逻辑是用原生 TypeScript 编写的,零依赖,仅仅在核心之上封装了轻量级的 Vue 和 React 组件适配层。

2. ⚡️ 内置"刚需"功能

很多瀑布流插件只管布局,不管体验。但这个库在 v1.0.3 版本中贴心地加入了很多实战刚需功能:

  • 骨架屏 (Skeleton Loading): 数据还没回来?直接通过 props 开启加载状态,自带脉冲动画的骨架屏,拒绝白屏。
  • 空状态 (Empty State): 列表为空时自动显示占位提示,不再需要自己写 v-if/v-else
  • 图片懒加载 (Lazy Load): 内置原生图片懒加载支持,性能直接拉满,不需要再引入额外的 lazyload 库。

3. 📐 两种核心布局策略

它不仅仅是简单的“排列”,还提供了两种最常用的响应式策略:

  • 固定列宽 (Fixed Column Width): 指定每列 250px,容器会自动计算能放下几列,并自动居中。适合大屏展示。
  • 固定列数 (Fixed Column Count): 指定“我要 3 列”,无论屏幕多大,宽度都会自动拉伸填满。适合移动端 H5。

快速上手

安装

非常简单,体积也很小:

npm install universal-waterfall-layout
# 或者
pnpm add universal-waterfall-layout

via Vue 3

在 Vue 中使用非常丝滑,你可以直接利用 slot 来自定义加载中和空状态的 UI:

<template>
  <Waterfall 
    :gap="20" 
    :columnWidth="250"
    :loading="isLoading"
    :lazyload="true"
  >
    <!-- 数据列表 -->
    <div v-for="item in items" :key="item.id" class="card">
      <img :src="item.image" alt="" />
      <p>{{ item.title }}</p>
    </div>
    
    <!-- 自定义加载骨架 -->
    <template #loading>
       <div class="my-skeleton">正在加载精彩内容...</div>
    </template>

    <!-- 自定义空状态 -->
    <template #empty>
       <div class="empty-box">暂无相关数据</div>
    </template>
  </Waterfall>
</template>

<script setup>
import { Waterfall } from 'universal-waterfall-layout/vue';
import { ref } from 'vue';

const isLoading = ref(true);
const items = ref([]);

// 模拟请求
setTimeout(() => {
  items.value = [{/*...*/}, {/*...*/}];
  isLoading.value = false;
}, 1000);
</script>

via React

React 版本同样拥有优秀的类型提示(TypeScript Friendly):

import { Waterfall } from 'universal-waterfall-layout/react';

const MyGallery = ({ data, loading }) => {
  return (
    <Waterfall 
      gap={15} 
      columnCount={3} // 移动端常用固定3列
      loading={loading}
      lazyload={true}
      loadingComponent={<div>Loading Skeleton...</div>}
      emptyComponent={<div>Nothing here yet!</div>}
    >
      {data.map(item => (
        <div key={item.id} className="card">
           <img src={item.url} />
        </div>
      ))}
    </Waterfall>
  );
};

via Vanilla JS (原生 HTML)

如果你不使用任何框架,或者在传统的 HTML 页面中使用,也完全没问题。核心库提供了直接操作 DOM 的能力:

<div id="waterfall-container">
  <div class="item"><img src="..." /></div>
  <div class="item"><img src="..." /></div>
  <!-- ... -->
</div>

<script type="module">
  import { WaterFallCore } from 'universal-waterfall-layout';

  const container = document.getElementById('waterfall-container');
  
  const waterfall = new WaterFallCore({
    container: container, // 必填:容器 DOM 元素
    gap: 15,              // 间距
    columnWidth: 220,     // 固定列宽策略
    lazyload: true        // 开启懒加载
  });
  
  // 如果后续通过 JS 动态添加了元素,可以手动触发布局更新
  // waterfall.updateItems();
  // waterfall.layout();
</script>

结语

在造轮子泛滥的今天,找到一个克制且好用的库并不容易。universal-waterfall-layout 没有过度封装,但把最核心的布局算法响应式处理加载体验做得非常扎实。

如果你正在开发图片流、商品墙或者作品集展示页面,强烈推荐试一试!

🔗 NPM 地址: universal-waterfall-layout

React从入门到出门第九章 资源加载新特性Suspense 原生协调原理与实战

大家好~ 提到 React 的 Suspense,很多开发者的第一印象是“用来做代码分割”或“配合 lazy 加载组件”。但在 React 19 之前,Suspense 在资源加载场景下始终有个致命短板:无法原生协调多个异步资源的加载状态,导致我们需要写大量模板代码处理“多资源并行加载”“依赖资源串行加载”等场景。

React 19 彻底解决了这个问题,推出了 Suspense 原生协调 特性,让多资源加载的状态管理变得极简。今天这篇文章,我们就从“旧版 Suspense 的痛点”出发,一步步拆解 React 19 原生协调的核心原理,再通过 4 个高频实战案例,带你彻底掌握这个新特性的用法,让资源加载逻辑更清晰、代码更简洁~

一、先回顾:旧版 Suspense 的资源加载痛点

在 React 19 之前,Suspense 虽然能处理单个异步资源的“加载中 fallback”,但面对多资源加载场景时,就显得力不从心了。我们先通过两个常见场景,看看旧版 Suspense 的问题所在。

1. 痛点 1:多资源并行加载,需手动管理整体状态

假设我们需要在页面中同时加载“用户信息”和“用户订单列表”两个异步资源,要求两个资源都加载完成后再展示页面,加载过程中显示统一的 loading。在 React 19 之前,我们需要手动管理两个资源的加载状态:

// React 19 之前:多资源并行加载的繁琐实现
import { useState, useEffect } from 'react';

// 异步请求函数
async function fetchUser() {
  const res = await fetch('/api/user');
  return res.json();
}

async function fetchOrders() {
  const res = await fetch('/api/orders');
  return res.json();
}

function UserDashboard() {
  // 手动管理两个资源的加载状态和结果
  const [user, setUser] = useState(null);
  const [orders, setOrders] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    setError(null);

    // 并行请求两个资源
    Promise.all([fetchUser(), fetchOrders()])
      .then(([userData, ordersData]) => {
        setUser(userData);
        setOrders(ordersData);
      })
      .catch((err) => setError(err.message))
      .finally(() => setLoading(false));
  }, []);

  // 手动处理 loading 和 error 状态
  if (loading) return <div>加载中...</div>;
  if (error) return <div>加载失败:{error}</div>;
  if (!user || !orders) return null;

  return (
    <div>
      <h2>{user.name} 的仪表盘</h2>
      <h3>我的订单</h3>
      <ul>
        {orders.map((order) => (
          <li key={order.id}>{order.title} - ¥{order.amount}</li>
        ))}
      </ul>
    </div>
  );
}

这种写法的问题很明显:需要手动用 useState 管理多个资源的状态,用 Promise.all 协调并行逻辑,代码模板化严重。如果资源数量增加到 3 个、4 个,状态管理会变得更加混乱。

2. 痛点 2:依赖资源串行加载,逻辑嵌套冗余

再看一个更复杂的场景:加载“用户订单详情”需要先加载“用户信息”(获取用户 ID),再根据用户 ID 加载“订单列表”,最后根据订单 ID 加载“订单详情”。这种串行依赖的场景,在旧版 React 中需要嵌套多层 Promise,逻辑繁琐:

// React 19 之前:串行资源加载的嵌套实现
function OrderDetail() {
  const [user, setUser] = useState(null);
  const [order, setOrder] = useState(null);
  const [detail, setDetail] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    setError(null);

    // 串行请求:用户信息 → 订单列表 → 订单详情
    fetchUser()
      .then((userData) => {
        setUser(userData);
        return fetchOrders(userData.id); // 依赖用户 ID
      })
      .then((ordersData) => {
        const firstOrder = ordersData[0];
        setOrder(firstOrder);
        return fetchOrderDetail(firstOrder.id); // 依赖订单 ID
      })
      .then((detailData) => {
        setDetail(detailData);
      })
      .catch((err) => setError(err.message))
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <div>加载中...</div>;
  if (error) return <div>加载失败:{error}</div>;
  if (!user || !order || !detail) return null;

  return (
    <div>
      <h2>{user.name} 的订单详情</h2>
      <p>订单号:{order.id}</p>
      <p>商品:{detail.goodsName}</p>
      <p>金额:¥{detail.amount}</p>
    </div>
  );
}

这种嵌套写法不仅可读性差,而且一旦某个环节需要修改(比如增加一个依赖资源),就需要改动多层代码,维护成本极高。

3. 痛点总结:旧版 Suspense 为何“不省心”?

旧版 Suspense 之所以无法解决这些问题,核心原因是:不具备原生的资源协调能力。它只能监听单个组件内部的异步资源(比如通过 use() 或 lazy 加载的资源),无法感知多个组件、多个资源之间的依赖关系和加载顺序。因此,开发者必须手动用 Promise 或状态管理库来协调这些资源,导致代码冗余、逻辑复杂。

二、React 19 核心升级:Suspense 原生协调原理

React 19 为 Suspense 新增了 原生协调能力,核心目标是:自动感知并协调多个异步资源的加载状态,支持并行、串行等多种加载策略,无需手动管理状态。这一特性彻底解决了旧版的痛点,让多资源加载变得极简。

1. 核心概念:什么是“原生协调”?

Suspense 原生协调,简单来说就是:当多个异步资源被 Suspense 包裹时,React 会自动收集所有资源的加载状态,根据你指定的策略(并行/串行)等待资源加载完成,再统一渲染内容;期间只显示一个 fallback,错误也会被统一捕获

关键变化在于:React 19 让 Suspense 从“单个资源的加载容器”升级为“多个资源的协调器”,能够主动管理多个资源的加载生命周期。

2. 核心原理:3 步实现资源协调

Suspense 原生协调的底层原理,依赖于 React 19 对“异步资源调度系统”的升级,核心流程可以拆解为 3 步:

  1. 资源收集阶段:当 Suspense 组件渲染时,React 会自动追踪其内部所有通过 use() 消费的异步资源(或通过 lazy 加载的组件),将这些资源的 Promise 收集到一个“资源队列”中;

  2. 加载协调阶段:React 根据 Suspense 的配置(默认并行,可通过配置实现串行),协调资源队列的加载顺序:

    1. 并行策略:同时触发所有资源的加载,等待所有资源都决议(成功/失败);
    2. 串行策略:按资源的依赖关系依次触发加载,前一个资源成功后再加载下一个;
  3. 状态统一阶段:在资源加载过程中,Suspense 始终显示 fallback;当所有资源都成功加载,Suspense 会渲染内部内容;如果任何一个资源加载失败,错误会被 ErrorBoundary 统一捕获。

Suspense 原生协调流程图

3. 关键技术:React 19 如何追踪资源依赖?

Suspense 原生协调的核心技术支撑,是 React 19 对 use() Hook 的增强和“资源依赖追踪机制”:

  • use() 增强:React 19 中的 use() 不仅能消费单个 Promise,还能将其对应的资源注册到最近的 Suspense 组件中,让 Suspense 感知到这个资源的存在;
  • 依赖追踪:React 会在组件渲染过程中,通过“上下文”记录当前 Suspense 组件与资源的对应关系,形成一个“资源依赖树”。当资源状态变化时,React 能通过这个树快速定位到对应的 Suspense 组件,触发重新协调。

简单来说,React 19 让 Suspense 和 use() 形成了“父子关系”:Suspense 是“协调器”,use() 消费的资源是“被协调的子节点”,协调器统一管理所有子节点的加载状态。

三、实战演练:4 个高频场景玩转 Suspense 原生协调

理解了核心原理后,我们通过 4 个真实业务场景,看看 React 19 Suspense 原生协调如何落地。所有案例都基于“use() + Suspense + ErrorBoundary”的组合,无需手动管理任何加载状态。

场景 1:多资源并行加载(基础用法)

需求:同时加载“用户信息”和“订单列表”,全部加载完成后展示页面,统一显示 loading,错误统一捕获。

// React 19:Suspense 原生协调实现多资源并行加载
import { use, Suspense } from 'react';

// 1. 定义异步请求函数(返回 Promise)
async function fetchUser() {
  const res = await fetch('/api/user');
  if (!res.ok) throw new Error('用户信息加载失败');
  return res.json();
}

async function fetchOrders() {
  const res = await fetch('/api/orders');
  if (!res.ok) throw new Error('订单列表加载失败');
  return res.json();
}

// 2. 子组件:分别使用 use() 消费资源
function UserInfo() {
  const user = use(fetchUser()); // 消费用户资源
  return <h2>{user.name} 的仪表盘</h2>;
}

function OrderList() {
  const orders = use(fetchOrders()); // 消费订单资源
  return (
    <div>
      <h3>我的订单</h3>
      <ul>
        {orders.map((order) => (
          <li key={order.id}>{order.title} - ¥{order.amount}</li>
        ))}
      </ul>
    </div>
  );
}

// 3. ErrorBoundary 组件(统一捕获错误)
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  render() {
    if (this.state.hasError) {
      return <div>加载失败:{this.state.error.message}</div>;
    }
    return this.props.children;
  }
}

// 4. 父组件:用 Suspense 包裹所有子组件,实现原生协调
function UserDashboard() {
  return (
    <ErrorBoundary>
      <Suspense fallback={<div>加载中...</div>}>
        <UserInfo />
        <OrderList />
      </Suspense>
    </ErrorBoundary>
  );
}

核心简化点:

  • 去掉了所有手动管理的 loading、error、数据状态;
  • 无需用 Promise.all 协调并行资源,Suspense 会自动收集 UserInfo 和 OrderList 中的两个资源,并行加载;
  • 加载状态和错误状态分别由 Suspense 和 ErrorBoundary 统一处理,代码逻辑极度简洁。

场景 2:依赖资源串行加载(通过嵌套 Suspense 实现)

需求:先加载“用户信息”(获取 user.id),再根据 user.id 加载“订单列表”,最后根据 order.id 加载“订单详情”,串行执行,统一显示 loading。

实现思路:利用 嵌套 Suspense 实现串行加载。因为内层 Suspense 会等待外层 Suspense 中的资源加载完成后,才会渲染自身内容,从而触发内层资源的加载。

// React 19:嵌套 Suspense 实现串行资源加载
import { use, Suspense } from 'react';

// 异步请求函数(订单和详情依赖前序资源的 ID)
async function fetchUser() {
  const res = await fetch('/api/user');
  if (!res.ok) throw new Error('用户信息加载失败');
  return res.json();
}

async function fetchOrders(userId) {
  const res = await fetch(`/api/orders?userId=${userId}`);
  if (!res.ok) throw new Error('订单列表加载失败');
  return res.json();
}

async function fetchOrderDetail(orderId) {
  const res = await fetch(`/api/orders/${orderId}/detail`);
  if (!res.ok) throw new Error('订单详情加载失败');
  return res.json();
}

// 子组件 1:加载用户信息
function User() {
  const user = use(fetchUser());
  return (
    <div>
      <h2>{user.name} 的订单详情</h2>
      <Suspense fallback={<div>加载订单列表中...</div>}>
        <OrderList userId={user.id} /> {/* 传入 user.id 给下一层 */}
      </Suspense>
    </div>
  );
}

// 子组件 2:加载订单列表(依赖 user.id)
function OrderList({ userId }) {
  const orders = use(fetchOrders(userId));
  const firstOrder = orders[0];
  return (
    <div>
      <p>当前订单:{firstOrder.id}</p>
      <Suspense fallback={<div>加载订单详情中...</div>}>
        <OrderDetail orderId={firstOrder.id} /> {/* 传入 order.id 给下一层 */}
      </Suspense>
    </div>
  );
}

// 子组件 3:加载订单详情(依赖 order.id)
function OrderDetail({ orderId }) {
  const detail = use(fetchOrderDetail(orderId));
  return (
    <div>
      <p>商品:{detail.goodsName}</p>
      <p>金额:¥{detail.amount}</p>
      <p>状态:{detail.status}</p>
    </div>
  );
}

// 父组件:最外层 Suspense 统一管理整体加载状态
function OrderDetailPage() {
  return (
    <ErrorBoundary>
      <Suspense fallback={<div>整体加载中...</div>}>
        <User />
      </Suspense>
    </ErrorBoundary>
  );
}

核心逻辑:

  • 外层 Suspense 包裹 User 组件,等待 fetchUser 完成;
  • User 组件加载完成后,渲染内层 Suspense 和 OrderList 组件,触发 fetchOrders(依赖 user.id);
  • OrderList 组件加载完成后,渲染最内层 Suspense 和 OrderDetail 组件,触发 fetchOrderDetail(依赖 order.id);
  • 整个过程是串行执行的,最外层 Suspense 会显示整体 loading,也可以为每一层设置单独的 fallback,实现更精细的加载状态展示。

场景 3:混合加载策略(部分并行 + 部分串行)

需求:加载“用户信息”后,并行加载“订单列表”和“用户收藏”,最后加载“订单详情”。即:串行(用户)→ 并行(订单+收藏)→ 串行(详情)。

// React 19:混合加载策略(串行+并行)
import { use, Suspense } from 'react';

// 异步请求函数
async function fetchUser() { /* ... */ }
async function fetchOrders(userId) { /* ... */ }
async function fetchCollections(userId) { /* ... */ }
async function fetchOrderDetail(orderId) { /* ... */ }

// 并行加载订单和收藏的组件
function OrderAndCollection({ userId }) {
  return (
    <div>
      {/* 两个组件并行加载,由内层 Suspense 协调 */}
      <OrderList userId={userId} />
      <CollectionList userId={userId} />
    </div>
  );
}

// 订单列表组件
function OrderList({ userId }) {
  const orders = use(fetchOrders(userId));
  return (
    <div>
      <h3>我的订单</h3>
      <ul>{orders.map(o => <li key={o.id}>{o.title}</li>)}</ul>
    </div>
  );
}

// 收藏列表组件
function CollectionList({ userId }) {
  const collections = use(fetchCollections(userId));
  return (
    <div>
      <h3>我的收藏</h3>
      <ul>{collections.map(c => <li key={c.id}>{c.name}</li>)}</ul>
    </div>
  );
}

// 订单详情组件
function OrderDetail({ orderId }) {
  const detail = use(fetchOrderDetail(orderId));
  return (
    <div>
      <h3>订单详情</h3>
      <p>商品:{detail.goodsName}</p>
    </div>
  );
}

// 父组件:通过嵌套 Suspense 实现混合策略
function MixedLoadPage() {
  return (
    <ErrorBoundary>
      <Suspense fallback={<div>加载用户信息中...</div>}>
        <UserWrapper />
      </Suspense>
    </ErrorBoundary>
  );
}

function UserWrapper() {
  const user = use(fetchUser());
  return (
    <div>
      <h2>{user.name} 的个人中心</h2>
      {/* 并行加载订单和收藏 */}
      <Suspense fallback={<div>加载订单和收藏中...</div>}>
        <OrderAndCollection userId={user.id} />
      </Suspense>
      {/* 加载订单详情(依赖订单列表) */}
      <Suspense fallback={<div>加载订单详情中...</div>}>
        <OrderDetail orderId="123" />
      </Suspense>
    </div>
  );
}

核心思路:通过“不同层级的 Suspense 包裹不同的资源组合”,实现混合加载策略。外层 Suspense 管理串行环节,内层 Suspense 管理并行环节,逻辑清晰,可扩展性强。

场景 4:动态资源加载(根据用户操作加载资源)

需求:页面初始只加载“用户信息”,用户点击“查看订单”按钮后,再加载“订单列表”,点击按钮时显示 loading。

实现思路:用 useState 控制动态资源的加载时机,只有当用户点击按钮后,才渲染依赖订单资源的组件,Suspense 会自动捕获这个动态加载的资源。

// React 19:动态资源加载(用户操作触发)
import { use, Suspense, useState } from 'react';

async function fetchUser() { /* ... */ }
async function fetchOrders(userId) { /* ... */ }

// 订单列表组件(动态加载)
function OrderList({ userId }) {
  const orders = use(fetchOrders(userId));
  return (
    <ul>
      {orders.map(o => <li key={o.id}>{o.title} - ¥{o.amount}</li>)}
    </ul>
  );
}

function UserPage() {
  const user = use(fetchUser());
  const [showOrders, setShowOrders] = useState(false); // 控制是否加载订单

  return (
    <div>
      <h2>{user.name} 的页面</h2>
      <button onClick={() => setShowOrders(true)}>查看订单</button>

      {/* 动态加载订单资源:只有 showOrders 为 true 时才渲染 */}
      {showOrders && (
        <Suspense fallback={<div>加载订单中...</div>}>
          <OrderList userId={user.id} />
        </Suspense>
      )}
    </div>
  );
}

// 父组件
function DynamicLoadPage() {
  return (
    <ErrorBoundary>
      <Suspense fallback={<div>加载用户信息中...</div>}>
        <UserPage />
      </Suspense>
    </ErrorBoundary>
  );
}

核心逻辑:

  • 初始状态下,showOrders 为 false,OrderList 组件不渲染,fetchOrders 不会被触发;
  • 用户点击按钮后,showOrders 变为 true,OrderList 组件渲染,use(fetchOrders()) 触发请求;
  • Suspense 捕获 fetchOrders 的加载状态,显示 fallback,加载完成后展示订单列表。

四、避坑指南:使用 Suspense 原生协调的 6 个关键注意点

在实际使用过程中,有几个容易踩坑的点,需要特别注意,避免出现状态混乱或性能问题。

1. 必须配合 use() 消费资源

Suspense 原生协调只能感知通过 use() 消费的异步资源(或通过 React.lazy 加载的组件)。如果直接在组件中用 Promise.then 处理异步请求,Suspense 无法捕获其状态,无法进行协调。

错误示例:

// 错误:直接用 Promise.then,Suspense 无法感知
function OrderList() {
  const [orders, setOrders] = useState(null);
  useEffect(() => {
    fetchOrders().then(setOrders);
  }, []);
  return <ul>{orders.map(o => &lt;li key={o.id}&gt;{o.title}&lt;/li&gt;)}&lt;/ul&gt;;
}

正确示例:

// 正确:用 use() 消费资源,Suspense 可感知
function OrderList() {
  const orders = use(fetchOrders());
  return <ul>{orders.map(o => <li key={o.id}>{o.title}</li>)}</ul>;
}

2. 错误必须用 ErrorBoundary 捕获

Suspense 只处理“加载中”状态,不处理“加载失败”状态。如果任何一个资源加载失败,会抛出错误,必须用 ErrorBoundary 组件捕获,否则会导致整个应用崩溃。

3. 嵌套 Suspense 的 fallback 优先级

当存在嵌套 Suspense 时,内层 Suspense 的 fallback 会覆盖外层的 fallback。如果需要显示整体加载状态,可以在最外层设置一个全局 fallback,内层设置局部 fallback,实现分层加载提示。

4. 避免过度嵌套 Suspense

虽然嵌套 Suspense 可以实现复杂的加载策略,但过度嵌套会增加 React 的协调成本,影响性能。建议根据实际需求,合理划分 Suspense 的层级,避免不必要的嵌套。

5. 动态资源加载的状态重置

当动态加载的资源(如场景 4 中的订单列表)需要重新加载时(比如用户切换了用户 ID),只需修改资源依赖的参数(如 userId),Suspense 会自动重新协调,触发新的请求,无需手动重置状态。

6. 不支持同步资源的协调

Suspense 原生协调只针对“异步资源”(返回 Promise 的资源)。如果资源是同步的(如直接导入的静态数据),Suspense 不会对其进行协调,会直接渲染内容。

五、核心总结

今天我们详细拆解了 React 19 Suspense 原生协调的核心原理和实战用法,核心要点总结如下:

  1. 核心价值:Suspense 从“单个资源加载容器”升级为“多资源协调器”,自动感知并协调多个异步资源的加载状态,支持并行、串行、混合等多种加载策略,彻底消除资源加载的模板代码;

  2. 核心原理:通过“资源收集-加载协调-状态统一”三步流程,结合 use() 的增强和资源依赖追踪机制,实现多资源的自动协调;

  3. 实战关键

    1. 并行加载:用一个 Suspense 包裹所有资源组件;
    2. 串行加载:用嵌套 Suspense 按依赖顺序包裹组件;
    3. 动态加载:用状态控制资源组件的渲染时机;
    4. 错误处理:必须配合 ErrorBoundary 统一捕获错误。
  4. 避坑要点:必须用 use() 消费资源,避免过度嵌套,错误处理不能少。

六、进阶学习方向

掌握了 Suspense 原生协调的基础用法后,可以进一步学习以下内容,深化理解:

  • Suspense 与 React 19 Action 的协同:如何用 Action 处理表单提交后的资源重新加载;
  • Suspense 服务器组件(Server Components)的结合:在服务端渲染场景下如何优化资源加载;
  • 资源预加载策略:如何通过 preload 等方式优化 Suspense 的加载体验;
  • 源码阅读:查看 React 19 源码中 Suspense 协调器的实现(重点看 react-reconciler 包中的 Suspense相关逻辑)。

如果这篇文章对你有帮助,欢迎点赞、收藏、转发~ 有任何问题也可以在评论区留言交流~ 我们下期再见!

通过英伟达平台免费调用 GLM4.7 教程

前言

最近在折腾 AI Agent 和模型接入相关的事情时,意外发现英伟达居然提供了一个面向开发者、可以免费调用模型 API 的平台。更关键的是,这个平台上不仅能用到一些主流开源模型,还能直接使用最近热度很高,号称开源 Coding 能力最强的 GLM4.7,以及综合表现相当稳的 minimax-m2.1

说实话,在如今 API 几乎全面 token 计费、随便一个复杂任务就轻松几十万甚至上百万 token 的背景下,这种可以正经做开发实验、不用一上来就烧钱的平台,对个人开发者和学习阶段的人来说非常友好。所以这篇文章主要做三件事:

  • 介绍 NVIDIA Build 平台本身能做什么
  • 记录从注册到实际调用 API 的完整流程
  • 分享一段真实使用下来的模型体验与限制

整体偏实践,结论也会尽量基于实际使用情况展开。

NVIDIA Build 模型平台

NVIDIA Build可以理解为英伟达官方提供的一个模型集成与调试平台。平台上已经部署了大量模型,涵盖文生文(Chat / Reasoning / Coding)、文生图 / 图生文、语音相关等模型。目前平台上可见的模型数量在 200+,基本覆盖了市面上主流的开源模型生态,例如:deepseek-R1 / deepseek-v3.xqwen3 / qwen-coderkimi-k2minimax-m2.1z-ai/glm4.7,平台本身还提供了在线 Playground(支持参数调节、tools 调用)、OpenAI 风格的 API 接口、模型示例代码一键生成等能力。

注册账号与 API Key 申请

账号注册说明

注意:疑似因为近期注册用户激增,新账号存在一定概率无法正常申请 API Key 的问题。在不影响账号合规性的前提下,比较稳妥的做法是使用非国内常见的邮箱注册,例如相对少见的邮箱(yeah.net),或国外邮箱(gmail.com)等,以及注册时使用浏览器无痕窗口,避免历史状态干扰。

创建账号

访问:build.nvidia.com/ ,点击左上角 Login

在弹窗中输入邮箱并点击 Next,随后填写注册信息并完成人机验证。

这里需要注意:在“更多注册选项”中可以看到 QQ、微信等方式,但不建议使用第三方快捷登录。在当前阶段,使用这些方式注册后,账号更容易出现 API 权限受限的情况。

完成注册后,会进入一些偏个性化设置的步骤(例如名称、偏好选项),按需填写即可。

如果账号状态正常,稍等片刻后,页面顶部会出现提示:

Please verify your account to get API access

点击右上角的 Verify 进入验证流程。

手机号验证

在验证弹窗中,将默认的 +1 修改为 +86,输入国内手机号即可。这里不需要刻意规避,国内手机号是可以正常通过验证的

点击 Send Code via SMS,完成验证码验证。

创建 API Key

验证完成后,点击右上角头像,进入 API Keys 管理页面。

如果账号状态正常,这里可以看到 Generate API Key 按钮。

点击后,输入一个 Key 名称(仅用于区分),过期时间选择 Never Expire

生成完成后,复制并妥善保存该 API Key,后续调用只会展示一次。

如果在 API Keys 页面完全看不到生成按钮,而是类似下图所示的提示界面,基本可以确认该账号当前无法使用 API 功能,建议更换账号重新注册。

使用 API Key 调用

本地客户端配置

只要是支持 OpenAI 风格接口的客户端基本都可以直接使用,我这里以 Jan 为例。

进入设置页,添加一个新的模型提供商。

  • Provider Name:自定义(例如 Nvidia
  • API Key:填写刚刚生成的 Key
  • Base URL:https://integrate.api.nvidia.com/v1

完成后添加模型。

例如添加 GLM4.7:

z-ai/glm4.7

新建会话并选择该模型后,即可正常对话。从体感上看,在普通对话场景下 token 输出速度非常快

获取模型列表

Jan 也支持直接调用 /models 接口获取模型列表,点击刷新即可自动拉取并添加。

需要注意的是:

  • /models 返回的是平台全量模型列表
  • 其中包含文生图、语音、多模态等模型
  • 并非所有模型都支持 chat / text-to-text

因此,如果在客户端中直接选择不支持 chat 的模型发送消息,会直接报错,这是模型能力不匹配,不是接口问题。

Playground 与模型调试

在 NVIDIA Build 平台的 Models 页面中,可以通过搜索 Chat / Reasoning 筛选支持的模型,或者在 Playground 页面的左上角看到所有支持文生文的模型列表。

kimi-k2 为例,点击模型后可以进入在线调试界面。

  • 左侧 Tools:可启用模型支持的工具
  • 右侧 Parameters:控制温度、最大 token 等参数

点击右上角 View Code,可以直接看到对应的 API 调用示例,包括 Base URL、Model ID、Messages 结构等。

Tools 调用示例

在部分模型中可以直接启用 tools,这里以 minimax-m2 为例演示。

启用 get_current_weather 工具后,询问某地天气,模型会自动进行 tools 规划与调用,并返回结果。

再次点击 View Code,可以看到完整的 tools 调用示例代码。

模型与接口

NVIDIA Build 提供的是 OpenAI 风格 API,接口层面兼容 chat.completions / responses,是否支持 chattools、多模态,取决于模型本身。所以,最稳妥的方式仍然是在平台 Models 页面中筛选 chat / reasoning,再决定是否接入到本地客户端或代码中。

使用体验与限制

说一下 GLM4.7 这个模型。它并不是我第一次用,在刚发布不久时我就已经通过一些第三方 API 供应商接触过,这次算是第二次较完整地使用。综合两次实际开发体验,说实话体感并不算好。

首先一个比较明显的问题是,在我目前常用的模型里(比如 qwen-code、gpt-5.1、gpt-5.2、Claude 等),只有 GLM4.7 会在生成的文件头部插入 Markdown 的代码块标记。这个问题在代码编辑和文件生成场景下尤其影响体验,需要额外清理,看起来就很蠢。

其次是执行效率问题,这一点让我感觉很奇怪。纯对话场景下它的响应速度是很快的,但一旦进入干活模式,比如稍微复杂一点的任务编排、代码修改或多步执行,单个任务可能会跑十几甚至二十分钟。问题不在于我不能接受模型执行复杂任务耗时,而是过程中偶尔会出现明显的停顿或卡住一段时间再继续,节奏非常不稳定。

一开始我也怀疑是 API 调用频率或限流导致的,但后来在同样的客户端、同样的任务复杂度下切换到 minimax-m2,发现并不是这个原因。minimax 的整体执行节奏要顺畅得多,调用也更激进,甚至可以轻松跑到 40 次 / 分钟 的平台上限,当然代价就是一旦规划稍微激进,就很容易直接撞上限流,接口报错,任务中断。

从平台层面来看,这个平台整体体验其实是非常不错的:模型选择多、接入成本低、示例清晰,对学习和实验阶段的开发者非常友好。平台的限制也比较直观明确,比如 API 调用频率限制在 40 次 / 分钟,超出后直接返回错误,这一点在 minimax-m2 上体现得尤为明显。

回到 GLM4.7 本身,客观来说它的功能是完全正常的:工具调用没问题,代码编辑能用,对话速度也快,只是在复杂任务执行阶段明显偏慢,且稳定性不够好。相比之下,minimax-m2 在相同条件下执行节奏更线性、更听话,只是更容易触发平台限流 (当然了,因为频繁触发限流所以我也没深度使用 minimax)

总结来说,GLM4.7 并不是不能干活,但实际开发体验一般,尤其是在需要长时间、连续执行任务的场景下,效率和节奏上的问题会被放大。

结语

实话说,这个平台在当前这个时间点,真的算是相当良心的存在。对想学习 AI Agent、工具调用、多模型编排的开发者来说,能够在不额外付费的情况下反复试错,本身就很有价值。

当然了,平台策略和风控状态可能随时变化,如果只是想白嫖一点体验,建议还是尽早注册,至少在账号状态正常的前提下,把 API Key 拿到手。

至于模型怎么选,建议多试、多对比,别迷信单一模型。能稳定把活干完的模型,才是好模型。

相关链接

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

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

在Unity URP Shader Graph中,Integer节点是一个基础但功能强大的工具节点,它允许开发者在着色器程序中定义和使用整型常量。虽然着色器编程通常以浮点数运算为主,但整数在特定场景下具有不可替代的作用,特别是在控制流程、数组索引、循环计数和条件判断等方面。

Integer节点的基本概念

Integer节点在Shader Graph中代表一个整型常量值。与其他节点不同,Integer节点不接收输入,而是直接输出一个用户定义的整数值。这个特性使得它成为着色器中的固定参数或控制变量的理想选择。

Integer节点的核心特点

  • 输出值为整型,但在着色器运算中会自动转换为浮点数
  • 可用于控制着色器中的离散状态和条件分支
  • 适合用于数组索引、循环计数和枚举类型的表示
  • 在性能优化方面,整数运算通常比浮点数运算更高效

在Shader Graph中的定位

Integer节点属于Shader Graph的Input类别,与其他的常量节点如Float、Vector2、Vector3等并列。它提供了在可视化着色器编程中处理整数数据的能力,弥补了传统节点图主要以浮点数为中心的设计局限。

节点属性和配置

端口配置

Integer节点只有一个输出端口,其配置如下:

  • 名称:Out
  • 方向:输出
  • 类型:Float
  • 绑定:无
  • 描述:输出整数值,但在类型系统中作为Float处理

端口特性的深入理解

虽然端口类型标记为Float,但实际上输出的是整数值。这种设计是因为HLSL和GLSL着色语言中,整数和浮点数在很多时候可以隐式转换,而且Shader Graph的内部数据类型系统主要以浮点数为基础。在实际着色器代码生成时,这个整数值会被正确地处理为整数类型。

控件参数

Integer节点提供了一个简单的控件用于配置其输出值:

  • 名称:无(在节点上直接显示数值)
  • 类型:Integer
  • 选项:无
  • 描述:定义节点输出的整数值

控件使用要点

  • 可以直接在节点上的输入框中输入整数值
  • 支持正负整数,范围通常受着色语言限制但足够大多数应用场景
  • 数值改变会实时更新节点预览和生成的着色器代码

生成的代码分析

根据官方文档,Integer节点生成的代码示例如下:

HLSL

float _Integer = 1;

代码生成机制深入解析

虽然示例代码显示变量被声明为float类型,但在实际的HLSL编译中,当这个值用于整数上下文时(如数组索引、循环计数器),编译器会进行适当的优化和处理。在更复杂的使用场景中,生成的代码可能会有不同的表现形式:

HLSL

// 当Integer节点用于数组索引时
int index = 2;
float value = _MyArray[index];

// 当用于循环控制时
for (int i = 0; i < _IterationCount; i++)
{
    // 循环体
}

变量命名规则

在生成的代码中,Integer节点对应的变量名称会根据节点在Graph中的名称自动生成。如果节点被命名为"TileCount",则生成的变量可能是_TileCount_Integer_TileCount,具体命名规则取决于Shader Graph的版本和配置。

Integer节点的实际应用

基础数值应用

Integer节点最直接的用途是提供整型常量值,用于控制着色器的各种参数:

  • 平铺和偏移控制:指定纹理平铺次数
  • 循环次数设置:控制for循环的迭代次数
  • 数组大小定义:确定固定大小数组的维度
  • 枚举状态表示:用整数代表不同的渲染状态或材质类型

纹理平铺示例

在纹理采样节点中,使用Integer节点控制平铺参数:

Integer节点(值:4) → TilingAndOffset节点 → SampleTexture2D节点

这种配置可以实现纹理的精确平铺控制,比如确保纹理在模型表面重复恰好4次,而不是4.5次或其他非整数值。

条件逻辑控制

Integer节点在着色器条件逻辑中发挥重要作用,特别是在需要离散状态判断的场景:

  • 多重材质切换:使用整数值选择不同的材质属性集
  • LOD级别控制:根据整数距离值切换细节级别
  • 特效强度分级:将连续的特效参数离散化为几个固定级别

状态机实现示例

通过结合Branch节点和Integer节点,可以实现简单的着色器状态机:

Integer节点(状态值) → Branch节点 → 不同的颜色/纹理输出

数组和循环操作

在高级着色器编程中,数组和循环是常见的编程结构,Integer节点在其中扮演关键角色:

  • 数组索引:安全地访问数组元素
  • 循环计数器:控制固定次数的循环迭代
  • 多维数组处理:计算行主序或列主序数组的索引

数组访问模式

For循环节点(使用Integer节点作为最大值) → 数组索引计算 → 数组元素访问

这种模式常见于图像处理效果,如卷积核操作、多光源累积计算等。

与其他节点的协同工作

与数学节点的配合

Integer节点可以与各种数学节点结合,实现更复杂的数值计算:

  • 算术运算:与Add、Subtract、Multiply、Divide节点配合进行整数运算
  • 比较运算:与Equal、NotEqual、Greater Than、Less Than节点结合实现条件判断
  • 插值运算:虽然整数本身不插值,但可以控制插值参数

运算精度注意事项

当Integer节点参与浮点数运算时,会自动提升为浮点类型。在需要保持整数精度的场景,应尽量避免与浮点数进行混合运算,或确保在关键步骤中使用适当的舍入函数。

与控制流节点的集成

Integer节点与Shader Graph的控制流节点紧密结合,实现动态的着色器行为:

  • Branch节点:使用整数值作为条件输入
  • For循环节点:提供循环次数和索引值
  • Switch节点:作为选择器输入,决定执行哪个分支

性能优化提示

在Shader Graph中使用整数控制流通常比使用浮点数更高效,因为整数比较和分支操作在GPU上的开销较小。特别是在移动平台上,这种优化更为明显。

高级应用技巧

动态整数参数

虽然Integer节点本身表示常量,但可以通过多种方式实现动态的整数参数:

  • 脚本驱动:通过C#脚本在运行时修改材质属性
  • 动画控制:使用Unity动画系统或时间节点驱动整数值变化
  • 顶点数据:从顶点颜色或UV通道中提取整数值

脚本集成示例

CSHARP

// C#脚本中设置整数值
material.SetInt("_IntegerParameter", 5);

数组和数据结构模拟

在着色器中模拟复杂数据结构时,Integer节点用于索引和管理:

  • 查找表索引:访问预计算的查找表
  • 状态矩阵:管理多维状态数组
  • 有限状态机:实现复杂的着色器行为切换

多维度索引计算

通过组合多个Integer节点和数学运算,可以计算多维数组的线性索引:

行索引 × 列数 + 列索引 = 线性索引

性能优化策略

合理使用Integer节点可以显著提升着色器性能:

  • 循环展开优化:使用小的整数值作为循环次数,促进编译器自动展开循环
  • 常量传播:整型常量的优化效果通常比浮点数更好
  • 分支预测:整数条件语句的预测效率通常更高

平台特定考虑

不同GPU架构对整数运算的支持程度不同。在编写跨平台着色器时,应了解目标平台的整数运算特性,特别是在移动设备上的性能表现。

实际案例研究

案例一:离散化颜色调色板

创建一个使用Integer节点选择预定义颜色的着色器:

  • 设置Integer节点作为颜色索引
  • 使用Branch节点或数组索引选择对应颜色
  • 应用选中的颜色到材质表面

这种技术常用于低多边形风格游戏或需要特定颜色方案的应用程序。

案例二:多纹理混合系统

实现一个根据整数值混合多个纹理的系统:

  • 使用Integer节点选择基础纹理、细节纹理和遮罩纹理
  • 根据整数值决定混合模式和强度
  • 创建可配置的多材质系统

案例三:程序化几何生成

在曲面细分或几何着色器中使用Integer节点控制细节级别:

  • 根据距离或重要性设置细分因子
  • 使用整数值确保对称和一致的几何分布
  • 优化性能的同时保持视觉质量

故障排除和最佳实践

常见问题解决

整数精度问题

  • 问题:大整数导致精度丢失或意外行为
  • 解决:确保使用的整数值在合理范围内,通常0-255对于大多数应用足够

类型转换错误

  • 问题:整数到浮点的隐式转换导致意外结果
  • 解决:在关键计算中显式处理类型转换,使用Round、Floor或Ceiling节点

性能问题

  • 问题:使用整数节点后着色器变慢
  • 解决:检查是否创建了复杂的依赖关系,简化节点网络

最佳实践建议

  • 命名规范:为Integer节点使用描述性名称,提高可读性
  • 数值范围:限制整数值在必要的最小范围内,避免不必要的内存占用
  • 文档注释:在Shader Graph中使用注释节点说明Integer节点的用途和预期值范围
  • 测试验证:在不同平台和设备上测试整数相关功能,确保一致的行为

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

uniapp开发问题记录(2)--“YYYY-MM-DD”时间字符串格式获取

问题

请求接口需要“YYYY-MM-DD”时间字符串,使用了new Date().toLocaleDateString('en-CA')来获取结果示例(东八区 2026-01-17 08:30) "2026-01-17" ,但在线上却行不通,接口获取不到数据。

原因:

在微信小程序(尤其是安卓真机)里写:

JavaScript

复制

new Date().toLocaleDateString('en-CA')

不会保证返回 "YYYY-MM-DD" ,而是可能得到:

  • "2024/06/03"(安卓最常见)
  • "2019年11月20日"(部分 iOS)
  • "11/20/2019"(地区语言设置不同)

总之格式完全取决于系统语言和宿主环境对 Intl 的支持程度,没有任何兼容性保障


最稳妥的写法(推荐)

直接手拼,彻底摆脱环境差异:

JavaScript

复制

const d = new Date()
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
// 结果一定是 "2024-06-03"

这段代码在开发者工具、iOS、安卓、线上版本表现完全一致,也是小程序社区最常用方案。

结论:
想要稳定拿到 "YYYY-MM-DD",就别用 toLocaleDateString,手拼最保险。

还有一个:

使用new Date().toISOString().slice(0, 10)也可以获取时间字符串

但要先确认一点:它按 UTC 零时区算日期

  • 在中国(东八区)
    今天 08:00 以前调用,得到的是昨天的日期
    08:00 及以后调用,得到的才是今天的日期

如果业务允许“UTC 自然日”或者只在服务器端/云函数里用,这条语句最简洁:

new Date().toISOString().slice(0, 10)   // 一定 "YYYY-MM-DD"

小程序端想拿“用户本地今天”就别用它,否则凌晨 0–8 点会出现“日期少一天”的坑;
继续用手拼:

const d = new Date()
`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`

一句话:
要 UTC 日期 → toISOString().slice(0,10) 最方便;
要本地日历 → 手拼最安全。

vercel新框架 json-render 介绍

2天前,vercel团队发布了一个新的框架 json-render ,是为了针对性解决 AI 生成 UI 的问题,官方有例子 可以尝试一下 json-render.dev/ ,跟我上篇文章介绍的 formily 集成 AI有点类似,下面来拆解一下

image.png

架构设计

json-render一共做了3件事:约定 AI 输出 → 解析 JSON → 渲染 UI

约定AI输出

开发者先在 json-render 的 registry(组件注册中心)里,把所有可用的组件(比如 Input、Button)、组件允许的 props、可触发的交互事件 actions(比如表单提交、数据导出)都声明清楚。框架会根据这份 registry 配置,自动生成一份约束性的 prompt,直接传给大模型。

解析大模型生成的JSON

然后是解析JSON,这里没什么太多新意,下面代码可以看到 只是做了简单的trim, image.png 实际用过大模型生成 JSON 的开发者都知道,这玩意不是百分百靠谱的 —— 哪怕给了再严格的约束 Prompt,大模型偶尔也会抽风,要么是 JSON 格式错乱、括号不闭合、字段缺失,要么干脆生成一半突然断了,甚至输出完全不相关的内容。值得一提的是支持了JSONL流式解析,生成一行解析一行,而且大模型生成单行的jsonl也比较稳定。

解析后的 JSON 会被转换成扁平的 UITree 结构,父子节点通过 key 列表关联。这种设计让 AI 生成扁平结构时,不用维护复杂的嵌套层级,出错率更低。前端渲染时,能通过 key 直接定位节点,不用递归遍历,渲染和更新的效率都更高。

image.png

渲染UI

框架会根据 UITree 里的节点类型(type)、属性(props),到 registry 里匹配对应的原生组件,把 props、actions 都注入进去,最终生成可交互的 UI 代码。

比如 UITree 里的{type: "Button", props: {text: "提交"}, action: "submit_form"},会直接映射到 registry 里注册的 Button 组件,自动绑定 submit_form 动作,不用开发者手写组件调用和事件绑定的代码。

总结

json-render的亮点在于 逻辑UI分离,可以适配不同的框架,扁平 UITree 结构和 JSONL 让AI犯错的概率更低

核心短板也很突出:对 AI 生成 JSON 的格式错误没有处理,需要结合 json-repair 这类工具,先对 AI 生成的 JSON 做格式修复,再交给框架解析

uniapp开发问题记录(1)

问题

页面结构:

<swiper :current="idx" @change="onSwipe" class="page-swiper" :duration="800" :circular="true"
:skip-hidden-item-layout="true">
<swiper-item v-for="(tab, i) in 7" :key="i">
<!-- 每页一个透明层 -->
<view class="page-fade" :class="i === idx ? 'fade-in' : 'fade-out'" v-show="visiblePages[i]"
@animationend="onFadeEnd(i)">
<scroll-view scroll-y="true" class="tab-scroll" refresher-enabled="true" @refresherrefresh="refresh"
:refresher-triggered="triggered">

下拉刷新时所绑定的事件被多次触发

原因:

7 个 scroll-view 是同时存在的(swiper-item 只是被 v-show 显隐,并没有销毁),
你把同一个变量 triggered 和同一个方法 refresh 同时绑给了 7 个实例,
于是任意一个下拉,7 个都会一起触发


解决思路:让“刷新状态”和“刷新函数”各自独立

  1. 给每个 scroll-view 单独的下标级状态
    triggered 做成数组 / 对象,而不是全局一个布尔值
data() {
  return {
    triggered: [false, false, false, false, false, false, false] // 7 份
  }
}
  1. 刷新事件也带下标
    模板里把索引传进去:
<scroll-view
  scroll-y
  refresher-enabled
  :refresher-triggered="triggered[i]"
  @refresherrefresh="refresh(i)">
</scroll-view>
  1. 方法里只操作对应下标的那一份
methods: {
  async refresh(i) {
    this.$set(this.triggered, i, true)          // 1. 开启第 i 个刷新动画
    await this.loadTabData(i)                   // 2. 只拉第 i 页的数据
    this.$set(this.triggered, i, false)         // 3. 关闭第 i 个刷新动画
  }
}

总结

swiper-item 里的 7 个 scroll-view并行存活的,
一个全局变量 + 一个全局函数就会被它们共享触发
triggeredrefresh 都改成**“按页独立”**,就能保证每页只触发自己的那一次。

前端AI应用开发深入理解 FunctionCall:让 LLM 拥有"超能力"的完整指南

想象一下,如果你的 AI 助手不仅能聊天,还能帮你查询实时天气、调用 API、执行数据库查询,那会是什么体验?FunctionCall 技术正是实现这一目标的关键!

🌟 什么是 FunctionCall?

FunctionCall(函数调用)是一种让大语言模型(LLM)能够调用外部工具函数的技术。它打破了传统 LLM 只能基于训练数据回答问题的限制,让 AI 能够:

  • 🔍 查询实时数据(如天气、股票价格)
  • 📊 访问数据库获取最新信息
  • 🔗 调用第三方 API 服务
  • ⚙️ 执行特定的业务逻辑

简单来说,FunctionCall 就像是给 LLM 装上了"手脚",让它不仅能"思考",还能"行动"!

🚀 为什么需要 FunctionCall?

传统的 LLM 有一个明显的局限:它只能基于训练时的数据回答问题。这意味着:

❌ 无法获取实时信息(比如今天的天气) ❌ 无法访问你的私有数据 ❌ 无法执行具体的操作(如发送邮件、创建订单)

而 FunctionCall 完美解决了这些问题!它让 LLM 成为了一个智能的"调度器",能够:

  • 理解用户的自然语言需求
  • 判断需要调用哪些工具
  • 自动提取函数参数
  • 将执行结果转化为自然语言回答

FunctionCall 完整工作流程

让我们通过一个实际例子来理解整个流程。假设用户问:"北京今天天气怎么样?"

🔧 步骤 1:定义工具描述

首先,我们需要告诉 LLM 有哪些工具可以使用。这就像给 LLM 提供一份"工具说明书":

const tools = [
  {
    type: "function",
    function: {
      name: "get_current_weather",
      description: "获取指定城市当前的实时天气",
      parameters: {
        type: "object",
        properties: {
          city: {
            type: "string",
            description: "城市名称",
          },
          unit: {
            type: "string",
            enum: ["摄氏度", "华氏度"],
            description: "温度单位",
          },
        },
        required: ["city"],
      },
    },
  },
];

这里的描述非常重要!LLM 会根据 description 来判断何时需要调用这个函数,根据 parameters 来提取用户输入中的参数值。

⚙️ 步骤 2:实现实际函数

定义好工具描述后,我们需要实现真正的函数逻辑:

function get_current_weather(city, unit = "摄氏度") {
  // 这里可以调用外部 API 来获取真实天气数据
  console.log(`正在获取 ${city} 的天气...`);
  const weather = {
    city: city,
    temperature: "25",
    unit: unit,
    forecast: "晴朗",
  };
  return JSON.stringify(weather);
}

这个函数可以调用任何你需要的 API 或执行任何业务逻辑。

🔗 步骤 3:建立函数映射

为了方便调用,我们创建一个映射对象:

const availableFunctions = {
  get_current_weather: get_current_weather,
};

这样 LLM 返回函数名后,我们就能快速找到对应的函数并执行。

🚀 步骤 4:发起对话

现在,我们将用户的问题和可用工具一起发送给 LLM:

const response = await zhipuClient.createCompletions({
  model: llmModel,
  messages: [
    {
      role: "user",
      content: "北京今天天气怎么样?",
    },
  ],
  tools: tools, // 告诉 LLM 有哪些工具可用
});

🤔 步骤 5:LLM 智能决策

LLM 会分析用户的问题,并做出智能判断:

情况 A:需要调用工具

{
  "tool_calls": [
    {
      "id": "call_123",
      "function": {
        "name": "get_current_weather",
        "arguments": "{\"city\": \"北京\", \"unit\": \"摄氏度\"}"
      }
    }
  ]
}

情况 B:不需要调用工具

{
  "content": "我是 AI 助手,无法直接回答天气问题。"
}

这就是 FunctionCall 的神奇之处:LLM 自动判断是否需要调用工具,并从自然语言中提取参数!

💻 步骤 6:执行本地函数

当 LLM 决定调用工具时,我们解析参数并执行函数:

const toolCall = message.tool_calls[0];
const functionName = toolCall.function.name;
const functionArgs = JSON.parse(toolCall.function.arguments);

const functionResponse = availableFunctions[functionName](
  functionArgs.city,
  functionArgs.unit
);

执行结果可能是:

{
  "city": "北京",
  "temperature": "25",
  "unit": "摄氏度",
  "forecast": "晴朗"
}

🔄 步骤 7:回传结果给 LLM

将函数执行结果作为上下文再次发送给 LLM:

const secondResponse = await zhipuClient.createCompletions({
  model: llmModel,
  messages: [
    { role: "user", content: "北京今天天气怎么样?" }, // 原始问题
    message, // LLM 的工具调用请求
    {
      role: "tool", // 工具执行结果
      tool_call_id: toolCall.id,
      content: functionResponse,
    },
  ],
});

这一步非常关键!LLM 需要看到完整的对话历史,包括:

  • 用户的问题
  • 自己的工具调用决策
  • 工具的执行结果

✅ 步骤 8:生成最终回答

最后,LLM 基于函数执行结果,生成自然语言的回答:

根据查询结果,北京今天的天气是晴朗,温度为 25 摄氏度。今天是个好天气,适合外出活动!

🎯 FunctionCall 的核心优势

1. 智能决策 🧠

LLM 自动判断是否需要调用工具,无需复杂的规则判断。比如:

  • 用户问"北京天气怎么样?" → 调用天气查询函数
  • 用户问"什么是人工智能?" → 直接回答,无需调用工具

2. 参数解析 📝

LLM 能够从自然语言中精准提取参数:

  • "北京今天天气怎么样?" → city="北京"
  • "查一下上海的天气,用华氏度" → city="上海", unit="华氏度"
  • "纽约的天气如何" → city="纽约"

3. 结果整合 💬

LLM 将结构化的函数结果转化为自然的语言回答,让用户体验更加流畅。

4. 多轮对话 🔄

支持连续的工具调用和上下文延续,可以进行复杂的任务链。

� 实际应用场景

场景 1:智能客服助手

// 工具:查询订单状态
function get_order_status(orderId) { ... }

// 工具:处理退款
function process_refund(orderId, reason) { ... }

// 用户:"我的订单 #12345 怎么还没到?"
// LLM 自动调用 get_order_status("12345")
// 回答:"您的订单 #12345 已发货,预计明天送达"

场景 2:数据分析助手

// 工具:查询数据库
function query_database(sql) { ... }

// 工具:生成图表
function generate_chart(data, type) { ... }

// 用户:"帮我看看上个月的销售数据"
// LLM 自动调用 query_database 和 generate_chart

场景 3:办公自动化

// 工具:发送邮件
function send_email(to, subject, body) { ... }

// 工具:创建日程
function create_calendar_event(title, time) { ... }

// 用户:"帮我给张三发封邮件约明天开会"
// LLM 自动调用 send_email 和 create_calendar_event

🛠️ 快速开始

1. 安装依赖

npm install zhipu-sdk-js dotenv

2. 配置环境变量

创建 .env 文件:

ZHIPUAI_API_KEY=your_api_key_here

3. 编写代码

import ZhipuAI from "zhipu-sdk-js";
import "dotenv/config";

const zhipuClient = new ZhipuAI({
  apiKey: process.env.ZHIPUAI_API_KEY,
});

// 定义工具、实现函数、运行对话...

4. 运行示例

node index.js

📚 技术栈

  • Node.js: 运行环境
  • zhipu-sdk-js: 智谱 AI 官方 SDK
  • GLM-4.5-Flash: 高性能大语言模型
  • dotenv: 环境变量管理

🔮 未来展望

FunctionCall 技术正在快速发展,未来可能支持:

  • 🤖 多工具并行调用
  • 📊 复杂的工具调用链
  • 🎯 自适应工具选择
  • 🔒 更安全的权限控制

代码示例

// index.js
import ZhipuAI from "zhipu-sdk-js";

import "dotenv/config";

// 步骤 1: 定义你的工具函数
const tools = [
  {
    type: "function",
    function: {
      name: "get_current_weather",
      description: "获取指定城市当前的实时天气",
      parameters: {
        type: "object",
        properties: {
          city: {
            type: "string",
            description: "城市名称",
          },
          unit: {
            type: "string",
            enum: ["摄氏度", "华氏度"],
            description: "温度单位",
          },
        },
        required: ["city"],
      },
    },
  },
];

// 步骤 2: 创建一个假想的函数来执行工具
function get_current_weather(city, unit = "摄氏度") {
  // 这里可以调用外部 API 来获取真实天气数据
  // 为了简化,我们直接返回一个模拟值
  console.log(`正在获取 ${city} 的天气...`);
  const weather = {
    city: city,
    temperature: "25",
    unit: unit,
    forecast: "晴朗",
  };
  return JSON.stringify(weather);
}

// 定义一个映射,将函数名和实际函数绑定
const availableFunctions = {
  get_current_weather: get_current_weather,
};

const zhipuClient = new ZhipuAI({
  apiKey: process.env.ZHIPUAI_API_KEY,
});

const llmModel = "glm-4.5-flash";
async function runConversation() {
  const userMessage = "北京今天天气怎么样?";

  // 向 LLM 发送用户问题,并提供可用的工具
  const response = await zhipuClient.createCompletions({
    model: llmModel,
    messages: [
      {
        role: "user",
        content: userMessage,
      },
    ],
    tools: tools,
  });
  debugger;
  const message = response.choices[0].message;

  // 步骤 3: 检查 LLM 是否决定调用函数
  if (message.tool_calls && message.tool_calls.length > 0) {
    const toolCall = message.tool_calls[0];
    const functionName = toolCall.function.name;
    const functionArgs = JSON.parse(toolCall.function.arguments);

    // 步骤 4: 在本地执行函数
    const functionResponse = availableFunctions[functionName](
      functionArgs.city,
      functionArgs.unit,
    );

    // 再次调用 LLM,将函数执行结果作为上下文
    const secondResponse = await zhipuClient.createCompletions({
      model: llmModel,
      messages: [
        { role: "user", content: userMessage },
        message, // 将 LLM 第一次的响应也作为上下文
        {
          role: "tool",
          tool_call_id: toolCall.id,
          content: functionResponse, // 将函数执行结果作为上下文
        },
      ],
    });

    // 最终返回 LLM 基于函数结果生成的回答
    console.log(secondResponse.choices[0].message.content);
  } else {
    // 如果 LLM 没决定调用函数,直接返回 LLM 的回答
    console.log(message.content);
  }
}

runConversation();
{
  "name": "function_call_llm",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "dotenv": "^17.2.3",
    "zhipu-sdk-js": "^1.0.0"
  }
}

📝 总结

FunctionCall 是连接 LLM 与现实世界的桥梁,它让 AI 从"聊天机器人"进化为"智能助手"。通过掌握这项技术,你可以:

  1. 构建更强大的 AI 应用
  2. 提供更智能的用户体验
  3. 实现更复杂的业务场景

现在就开始尝试吧! 让你的 AI 助手拥有真正的"超能力"!🚀


💡 小贴士:本文档配套了完整的示例代码,你可以直接运行体验 FunctionCall 的强大功能。如有问题,欢迎交流讨论!

Vue3响应式API全指南:ref/reactive及衍生API的区别与最佳实践

Vue3基于Proxy重构了响应式系统,提供了一套灵活的API矩阵——核心的ref与reactive、浅响应式的shallowRef/shallowReactive、只读封装的readonly/shallowReadonly。这些API看似功能重叠,实则各有适配场景,误用易导致响应式失效或性能冗余。本文将从特性本质、核心区别、代码示例、适用场景四个维度,系统拆解六大API,帮你精准选型、规避踩坑。

一、核心基础:ref 与 reactive

ref和reactive是Vue3响应式开发的基石,均用于创建响应式数据,但针对的数据类型、访问方式有明确边界,是后续衍生API的设计基础。

1. 核心特性与区别

维度 ref reactive
支持类型 基本类型(string/number/boolean等)+ 引用类型 仅支持引用类型(对象/数组),基本类型传入无响应式效果
实现原理 封装为Ref对象(含.value属性),基本类型靠Object.defineProperty拦截.value,引用类型内部调用reactive 直接通过Proxy拦截对象的属性读取/修改,天然支持嵌套属性响应式
操作方式 脚本中需通过.value访问/修改,模板中自动解包(无需.value) 脚本、模板中均直接操作属性(无.value冗余)
解构特性 解构后丢失响应式,需用toRefs/toRef转换保留 直接解构失效,通过toRefs可将属性转为Ref对象维持响应式
响应式深度 默认深响应式(嵌套对象属性变化触发更新) 默认深响应式(嵌套对象属性变化触发更新)

2. 代码示例

import { ref, reactive, toRefs } from 'vue';

// ref使用:基本类型+引用类型
const count = ref(0);
count.value++; // 脚本中必须用.value
console.log(count.value); // 1

const user = ref({ name: '张三', age: 20 });
user.value.age = 21; // 嵌套属性修改,触发响应式

// reactive使用:仅引用类型
const person = reactive({ name: '李四', info: { height: 180 } });
person.name = '王五'; // 直接操作属性
person.info.height = 185; // 嵌套属性深响应式

// 解构处理
const { name, age } = toRefs(user.value); // 保留响应式
name.value = '赵六'; // 触发更新

3. 适用场景

ref:优先用于基本类型响应式(如计数器、开关状态、输入框值);单独维护单个引用类型数据(无需复杂嵌套解构);组合式API中作为默认选择,灵活性更高。

reactive:适用于复杂引用类型(如用户信息、列表数据、表单聚合状态);希望避免.value冗余,追求更直观的属性操作;组件内部状态聚合管理(相关属性封装为一个对象,可读性更强)。

二、性能优化:shallowRef 与 shallowReactive

ref和reactive的深响应式会递归处理所有嵌套属性,对大型对象/第三方实例而言,可能产生不必要的性能开销。浅响应式API仅拦截顶层数据变化,专为性能优化场景设计。

1. 核心特性与区别

维度 shallowRef shallowReactive
支持类型 基本类型 + 引用类型(同ref) 仅引用类型(同reactive)
响应式深度 仅拦截.value的引用替换,嵌套属性变化不触发更新 仅拦截顶层属性变化,嵌套属性变化无响应式效果
更新触发 需替换.value引用(如shallowRef.value = 新对象);嵌套修改需用triggerRef手动触发更新 仅修改顶层属性触发更新,嵌套属性修改完全不拦截
使用成本 嵌套修改需手动触发更新,有额外编码成本 无需手动触发,但需牢记仅顶层响应式,易踩坑

2. 代码示例

import { shallowRef, shallowReactive, triggerRef } from 'vue';

// shallowRef示例
const shallowUser = shallowRef({ name: '张三', info: { age: 20 } });
shallowUser.value.info.age = 21; // 嵌套修改,无响应式
shallowUser.value = { name: '李四', info: { age: 22 } }; // 替换引用,触发更新
triggerRef(shallowUser); // 手动触发更新(嵌套修改后强制同步)

// shallowReactive示例
const shallowPerson = shallowReactive({
  name: '王五',
  info: { height: 180 }
});
shallowPerson.name = '赵六'; // 顶层修改,触发更新
shallowPerson.info.height = 185; // 嵌套修改,无响应式

3. 适用场景

shallowRef:引用类型数据仅需整体替换(如大型图表配置、第三方库实例、不可变数据);明确不需要嵌套属性响应式,追求极致性能(避免递归Proxy开销)。

shallowReactive:复杂对象仅需顶层属性响应式(如表单顶层状态、静态嵌套数据的配置对象);大型对象场景下,规避深响应式的性能损耗,且无需频繁修改嵌套属性。

注意:浅响应式API并非“银弹”,仅在明确不需要深层响应式时使用,否则易导致响应式失效问题,增加调试成本。

三、只读防护:readonly 与 shallowReadonly

在父子组件通信、全局常量管理等场景,需禁止数据被修改,此时可使用只读API。它们会拦截修改操作(开发环境抛警告),同时保留原数据的响应式特性(原数据变化时,只读数据同步更新)。

1. 核心特性与区别

维度 readonly shallowReadonly
支持类型 引用类型为主(基本类型只读无实际意义) 引用类型为主(基本类型只读无实际意义)
只读深度 深只读:顶层+所有嵌套属性均不可修改 浅只读:仅顶层属性不可修改,嵌套属性可正常修改
修改拦截 任何层级修改均被拦截,开发环境抛警告 仅顶层修改被拦截,嵌套修改无拦截、无警告
响应式保留 保留深响应式:原数据任意层级变化,只读数据同步更新 保留浅响应式:原数据变化(无论层级),只读数据同步更新

2. 代码示例

import { readonly, shallowReadonly, reactive } from 'vue';

// 原始响应式数据
const original = reactive({
  name: '张三',
  info: { age: 20 }
});

// readonly示例
const readOnlyData = readonly(original);
readOnlyData.name = '李四'; // 顶层修改,被拦截(抛警告)
readOnlyData.info.age = 21; // 嵌套修改,被拦截(抛警告)
original.name = '李四'; // 原数据变化,只读数据同步更新
console.log(readOnlyData.name); // 李四

// shallowReadonly示例
const shallowReadOnlyData = shallowReadonly(original);
shallowReadOnlyData.name = '王五'; // 顶层修改,被拦截(抛警告)
shallowReadOnlyData.info.age = 22; // 嵌套修改,正常执行(无警告)
console.log(shallowReadOnlyData.info.age); // 22

3. 适用场景

readonly:完全禁止修改的响应式数据(如全局常量配置、接口返回的不可变数据);父子组件通信的Props(Vue内部默认对Props做readonly处理,防止子组件修改父组件状态);需要严格防护数据完整性的场景。

shallowReadonly:仅需禁止顶层属性修改,嵌套属性允许微调(如父组件传递给子组件的复杂对象,子组件可修改嵌套细节但不能替换整体);追求性能优化,避免深只读的递归拦截开销(大型对象场景更明显)。

四、API选型总指南与避坑要点

1. 快速选型流程图

  1. 明确需求:是否需要响应式?→ 不需要则直接用普通变量;需要则进入下一步。
  2. 数据类型:基本类型→只能用ref;引用类型→进入下一步。
  3. 修改权限:需要禁止修改→readonly(深防护)/shallowReadonly(浅防护);允许修改→进入下一步。
  4. 响应式深度:仅需顶层响应式→shallowRef/shallowReactive;需要深层响应式→ref/reactive。
  5. 操作习惯:避免.value→reactive;接受.value或基本类型→ref。

2. 常见坑点规避

  • ref解构丢失响应式:务必用toRefs/toRef转换,而非直接解构。
  • reactive传入基本类型:无响应式效果,需改用ref。
  • 浅响应式嵌套修改失效:shallowRef需用triggerRef手动触发,shallowReactive避免依赖嵌套属性更新。
  • readonly修改原数据:只读API仅拦截对自身的修改,原数据仍可修改,需注意数据溯源。
  • ref嵌套对象修改:无需额外处理,内部已转为reactive,直接修改.value.属性即可。

五、总结

Vue3的响应式API设计围绕“灵活性”与“性能”两大核心:ref/reactive构建基础响应式能力,适配绝大多数日常场景;shallow系列API针对性优化性能,降低大型数据的响应式开销;readonly系列API保障数据安全性,适配只读场景。

核心原则是“按需选型”——无需为简单场景引入复杂API,也无需为性能牺牲开发效率。掌握各API的响应式深度、修改权限、操作方式,就能在项目中精准运用,打造高效、健壮的响应式系统。

高级异步:并发控制与性能优化

上一期我们掌握了 Fetch + async/await 的基本网络请求能力。
但真实项目中经常遇到下面这些场景:

  • 需要同时请求 50~200 个接口
  • 不能让全部请求同时发出(服务器会限流或直接封 IP)
  • 请求顺序有依赖,但又想尽可能并行
  • 防止瀑布式请求把页面加载时间拉长到几秒甚至十几秒

这一期我们就来系统解决这些“高级异步”问题。

1. 并发控制的核心思路

并发方式 最大同时请求数 适用场景 实现难度
全部同时发 无限制 接口少、服务器不限流 ★☆☆☆☆
Promise.all 全部同时 数量少(<30个) ★☆☆☆☆
固定并发数分批 3~10 大批量请求 + 服务器有限流 ★★☆☆☆
令牌桶/滑动窗口 动态控制 严格限流、需要平滑流量 ★★★★☆
带优先级 + 超时 动态 + 优先级 核心接口优先、边缘接口可丢弃 ★★★★☆

2. 最常用的几种实现方式(代码示例)

方式1:简单粗暴版 - Promise.all + 分组

async function fetchAllInBatches(urls, batchSize = 6) {
  const results = [];
  
  for (let i = 0; i < urls.length; i += batchSize) {
    const batch = urls.slice(i, i + batchSize);
    const batchPromises = batch.map(url => fetch(url).then(r => r.json()));
    
    const batchResults = await Promise.all(batchPromises);
    results.push(...batchResults);
  }
  
  return results;
}

最常用、最好理解,适合绝大多数场景。

方式2:并发限制工具函数(推荐生产使用)

class ConcurrencyLimiter {
  constructor(maxConcurrent) {
    this.max = maxConcurrent;
    this.running = 0;
    this.queue = [];
  }

  async run(task) {
    if (this.running >= this.max) {
      await new Promise(resolve => this.queue.push(resolve));
    }
    
    this.running++;
    try {
      return await task();
    } finally {
      this.running--;
      if (this.queue.length > 0) {
        const next = this.queue.shift();
        next();
      }
    }
  }
}

// 使用示例
const limiter = new ConcurrencyLimiter(5); // 最多同时5个

const allResults = await Promise.all(
  urls.map(url => 
    limiter.run(() => fetch(url).then(r => r.json()))
  )
);

方式3:p-limit / p-queue(社区最流行库)

// 使用 p-limit(非常推荐,体积小、API 优雅)
import pLimit from 'p-limit';

const limit = pLimit(4); // 最多4个并发

const results = await Promise.all(
  items.map(item => 
    limit(() => processItem(item))
  )
);

目前(2026年)前端项目中最受欢迎的并发控制方案,几乎成为“标配”。

3. 性能优化实战技巧

优化手段 效果 实现成本 推荐指数
请求合并(BFF/GraphQL) 1次请求代替 N 次 ★★★☆☆ ★★★★★
预请求 / prefetch 用户还没点已经开始请求 ★★☆☆☆ ★★★★☆
瀑布 → 并行转换 串行 8s → 并行 2.5s ★★☆☆☆ ★★★★★
缓存(memory + IndexedDB) 重复请求直接秒返回 ★★★☆☆ ★★★★☆
弱网优化(骨架屏+延迟加载) 感知速度提升明显 ★★☆☆☆ ★★★★☆
请求优先级 + 可取消 核心数据优先,边缘可丢 ★★★★☆ ★★★☆☆

经典案例:商品详情页的“并行优化”

优化前(典型瀑布)

  1. 获取商品基本信息 → 2. 获取 SKU → 3. 获取推荐商品 → 4. 获取评论 → 5. 获取店铺信息

优化后

async function loadProductPage(productId) {
  const [basic, skus, comments, shop] = await Promise.all([
    fetchBasic(productId),
    fetchSkus(productId),           // 可与 basic 并行
    fetchComments(productId),       // 可提前请求
    fetchShopInfo(basic.shopId)     // 依赖 basic,但可延迟
  ]);

  const recommends = await fetchRecommends(basic.category); // 最后请求

  return { basic, skus, comments, shop, recommends };
}

时间从 8~10秒 → 2.5~3.5秒(弱网环境下感知差距更大)

4. 小结与进阶路线

当前阶段你应该已经掌握:

  • Promise.all / allSettled 的合理使用
  • 固定并发数的实现方式(手写 + p-limit)
  • 如何把瀑布式请求改造成并行
  • 基本的请求超时与取消(AbortController)

下一阶段值得深入的方向:

  • 更复杂的流量控制(令牌桶、漏桶)
  • 请求优先级队列
  • 自动重试 + 指数退避
  • Service Worker + Cache API 的离线优化
  • Web Workers 做真正的后台并发计算

我们下一期见~
(最后一期:异步编程最佳实践与调试技巧 + 系列总结)

留言区互动:
你项目里最大的并发请求量是多少?
用过哪些并发控制方案?效果如何?

Fetch API 与异步网络请求

上一期我们掌握了 async/await 的优雅写法,今天就把它真正用起来:
学习现代浏览器中最推荐的网络请求方式 —— Fetch API

Fetch 是 Promise 风格的网络请求 API,取代了古老的 XMLHttpRequest,成为目前前端获取数据的标准方式。

1. 最基础的 GET 请求

async function getUser() {
  try {
    const response = await fetch('https://api.example.com/users/123');
    
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    
    const user = await response.json();
    console.log('用户信息:', user);
    return user;
  } catch (error) {
    console.error('获取用户失败:', error);
  }
}

关键点:

  • fetch() 返回一个 Promise,resolve 后得到 Response 对象
  • response.ok 判断请求是否成功(状态码 200~299)
  • response.json() 也是返回 Promise,需要 await

2. 常用请求方式完整示例

// GET - 带查询参数
async function searchUsers(keyword) {
  const url = `https://api.example.com/users?q=${encodeURIComponent(keyword)}`;
  const response = await fetch(url);
  return response.json();
}

// POST - 创建资源
async function createPost(title, content) {
  const response = await fetch('https://api.example.com/posts', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`
    },
    body: JSON.stringify({ title, content })
  });
  
  if (!response.ok) throw new Error('创建失败');
  return response.json();
}

// PUT - 更新资源
async function updateUser(id, data) {
  const response = await fetch(`https://api.example.com/users/${id}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  });
  return response.json();
}

// DELETE
async function deletePost(id) {
  await fetch(`https://api.example.com/posts/${id}`, {
    method: 'DELETE'
  });
  // 通常 204 No Content,不需要 .json()
}

3. 实用技巧与最佳实践

场景 推荐写法 说明
超时控制 使用 AbortController 防止请求挂起太久
统一错误处理 封装 fetch 函数 统一处理 4xx/5xx 和网络错误
带凭证(cookie) credentials: 'include' 跨域携带 cookie
防止缓存 cache: 'no-store' 或添加时间戳 开发调试或实时数据时常用
并发请求 Promise.all + fetch 同时请求多个接口
流式响应 response.body.getReader() 处理大文件或 SSE

超时示例(非常推荐)

async function fetchWithTimeout(url, timeout = 8000) {
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), timeout);

  try {
    const response = await fetch(url, { signal: controller.signal });
    clearTimeout(id);
    return response;
  } catch (error) {
    clearTimeout(id);
    if (error.name === 'AbortError') {
      throw new Error('请求超时');
    }
    throw error;
  }
}

4. Fetch vs axios vs jQuery.ajax 对比(2025-2026 视角)

特性 Fetch axios jQuery.ajax
原生 否(需引入)
Promise 支持 原生 原生 + 更多封装 较老版本需转换
自动 JSON 否(需手动 .json()) 部分支持
请求/响应拦截 无(需自己实现) 原生支持
超时处理 需 AbortController 内置 timeout 配置 内置
取消请求 AbortController CancelToken / AbortSignal 较麻烦
浏览器兼容 几乎全部现代浏览器 所有(含旧版) 所有
包体积 0kb ~13kb(gz) 很大

结论
2026 年,大多数中大型项目仍然首选 axios(因为生态完善、拦截器好用),
所有新项目都应该优先掌握原生 Fetch,它是标准、轻量、无依赖的未来方向。

5. 小结

Fetch + async/await 是目前最“现代感”的网络请求组合方式:

  • 语法简洁
  • 原生无依赖
  • 与 Promise 生态无缝衔接
  • 支持所有现代特性(Abort、流、form-data 等)

下一期我们进入更进阶的内容:
高级异步:并发控制与性能优化
—— 如何优雅地处理 100 个并发请求?如何避免瀑布式请求?如何做请求节流?

我们下期见~

留言区互动:
你在实际项目中是用原生 Fetch 还是 axios 更多?
有没有遇到过“明明接口成功了却进 catch”的奇怪情况?😂

Nuxt 写后端

写接口

// server/api/test.ts

export default defineEventHandler(async (event) => {
  // 写这个接口的逻辑
})

可以直接返回textjsonhtmlstream(文件流等流)

放心好了,nuxt支持热模块替换和自动导入。改代码直接看到效果。无需手动写import语句。

// vue
<script setup>
// 要写import
import { ref } from 'vue'
import MyButton from '@/components/MyButton.vue'
import { useFetch } from '@/composables/useFetch'

const count = ref(0)
const { data } = useFetch('/api/data')
</script>

<template>
  <MyButton>点击</MyButton>
</template>

Nuxt的自动导入

<script setup>
const count = ref(0) // 自动从`vue`导入ref
const { data } = useFetch('/api/data') // 自动从 composables/ 导入
</script>

<template>
  <MyButton>点击</MyButton>
</template>

Nuxt自动导入了哪些

  • Vue APIrefcomputedonMounted

  • Nuxt ComposablesuseFetchuseAsyncData

  • Vue RouteruseRouteruseRoute

  • 组件components/目录下的所有组件

  • 工具函数utils/composables/目录下的函数

  • VueUse:如果安装了@vueuse/nuxt

<template>
  <div>
    <h1>{{ title }}</h1>
    <MyComponent />
  </div>
</template>

<script setup>
// 1. 不需要导入 MyComponent - 自动导入
// 2. 修改后页面局部更新 - HMR
const title = ref('欢迎') // ref 也是自动导入的
</script>

部署 - 通用

云服务器构建Nuxt应用

  • Cloudflare
  • Netlify
  • Edge

混合渲染

自定义路由

// nuxt.config.ts

export default defineNuxtConfig({
  routeRules: {
    // 为 SEO 目的在构建时生成
    '/': { prerender: true },
    '/api/*': { cache: { maxAge: 60 * 60 } },
    '/old-page': {
      redirect: { to: '/new-page', statusCode: 302 }
    }
  }
})

目录结构

  • 通过nuxt.configapp.config在项目之间共享可重用的配置预设。
  • components/目录做组件库。
  • composables/utils/目录创建工具和组合式函数库。
  • layers/目录做项目的层

每个层的srcDir都会自动创建命名的层别名。可以用#layers/test访问~~/layers/test层。

也可以自定义nuxt.config文件去设置添加extends去加一个层:

// nuxt.config.ts

export default defineNuxtConfig({
  extends: [
    '../base', // 从本地层去加
    '@my-themes/awesome', // 从安装的包去加
    'github:my-themes/awesome#v1' // 从git库中加
  ]
})

github私有库的要加token

// nuxt.config.ts

export default defineNuxtConfig({
  extends: [
    '../base', // 从本地层去加
    '@my-themes/awesome', // 从安装的包去加
    ['github:my-themes/awesome#v1': { auth: process.env.GITHUB_TOKEN }] // 从git库中加
  ]
})

起别名

// nuxt.config.ts
export default defineNuxtConfig({
  extends: [
    [
      'github:my-themes/awesome',
      { 
        meta: {
          name: 'my-awesome-theme',
        },
      },
    ],
  ]
})

预渲染

Nuxt允许页面在构建时进行静态渲染,提高SEO。

为啥选Nuxt。它SEO优秀啊。在应用中,我们可以选几个页面在构建时进行渲染。有请求时,Nuxt会提供预构建的页面,而不是动态生它们。

基于爬取的预渲染

nuxt generate命令。通过Nitro爬虫去建和预渲染应用。

建站点,启动一个nuxt实例。

选择性预渲染

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    prerender: {
      routes: ["/user/1", "/user/2"],
      ignore: ["/dynamic"],
    },
  },
});

NASA项目一些关键代码展示

client 文件夹下

结构

image.png

app.js

import {
  BrowserRouter as Router,
} from "react-router-dom";
import {
  Arwes,
  SoundsProvider,
  ThemeProvider,
  createSounds,
  createTheme,
} from "arwes";

import AppLayout from "./pages/AppLayout";

import { theme, resources, sounds } from "./settings";

const App = () => {
  return <ThemeProvider theme={createTheme(theme)}>
    <SoundsProvider sounds={createSounds(sounds)}>
      <Arwes animate background={resources.background.large} pattern={resources.pattern}>
        {anim => (
          <Router>
            <AppLayout show={anim.entered} />
          </Router>
        )}
      </Arwes>
    </SoundsProvider>
  </ThemeProvider>;
};

export default App;

setting.js

const resources = {
  background: {
    small: "/img/background-small.jpg",
    medium: "/img/background-medium.jpg",
    large: "/img/background-large.jpg",
  },
  pattern: "/img/glow.png",
};

const sounds = {
  shared: {
    volume: 0.5,
  },
  players: {
    click: {
      sound: { src: ["/sound/click.mp3"] },
      settings: { oneAtATime: true }
    },
    typing: {
      sound: { src: ["/sound/typing.mp3"] },
      settings: { oneAtATime: true }
    },
    deploy: {
      sound: { src: ["/sound/deploy.mp3"] },
      settings: { oneAtATime: true }
    },
    success: {
      sound: {
        src: ["/sound/success.mp3"],
        volume: 0.2,
      },
      settings: { oneAtATime: true }
    },
    abort: {
      sound: { src: ["/sound/abort.mp3"] },
      settings: { oneAtATime: true }
    },
    warning: {
      sound: { src: ["/sound/warning.mp3"] },
      settings: { oneAtATime: true }
    },
  }
};

const theme = {
  color: {
    content: "#a1ecfb",
  },
  padding: 20,
  responsive: {
    small: 600,
    medium: 800,
    large: 1200
  },
  typography: {
    headerFontFamily: '"Titillium Web", "sans-serif"',
  },
};

export {
  resources,
  sounds,
  theme,
};

pages/Launch.js

import { useMemo } from "react";
import { Appear, Button, Loading, Paragraph } from "arwes";
import Clickable from "../components/Clickable";

const Launch = props => {
  const selectorBody = useMemo(() => {
    return props.planets?.map(planet => 
      <option value={planet.keplerName} key={planet.keplerName}>{planet.keplerName}</option>
    );
  }, [props.planets]);

  const today = new Date().toISOString().split("T")[0];

  return <Appear id="launch" animate show={props.entered}>
    <Paragraph>Schedule a mission launch for interstellar travel to one of the Kepler Exoplanets.</Paragraph>
    <Paragraph>Only confirmed planets matching the following criteria are available for the earliest scheduled missions:</Paragraph>
    <ul>
      <li>Planetary radius &lt; 1.6 times Earth's radius</li>
      <li>Effective stellar flux &gt; 0.36 times Earth's value and &lt; 1.11 times Earth's value</li>
    </ul>

    <form onSubmit={props.submitLaunch} style={{display: "inline-grid", gridTemplateColumns: "auto auto", gridGap: "10px 20px"}}>
      <label htmlFor="launch-day">Launch Date</label>
      <input type="date" id="launch-day" name="launch-day" min={today} max="2040-12-31" defaultValue={today} />
      <label htmlFor="mission-name">Mission Name</label>
      <input type="text" id="mission-name" name="mission-name" />
      <label htmlFor="rocket-name">Rocket Type</label>
      <input type="text" id="rocket-name" name="rocket-name" defaultValue="Explorer IS1" />
      <label htmlFor="planets-selector">Destination Exoplanet</label>
      <select id="planets-selector" name="planets-selector">
        {selectorBody}
      </select>
      <Clickable>
        <Button animate 
          show={props.entered} 
          type="submit" 
          layer="success" 
          disabled={props.isPendingLaunch}>
          Launch Mission ✔
        </Button>
      </Clickable>
      {props.isPendingLaunch &&
        <Loading animate small />
      }
    </form>
  </Appear>
};

export default Launch;

pages/AppLayout.js


import {
  useState,
} from "react";
import {
  Switch,
  Route,
} from "react-router-dom";
import {
  Frame,
  withSounds,
  withStyles,
} from "arwes";

import usePlanets from "../hooks/usePlanets";
import useLaunches from "../hooks/useLaunches";

import Centered from "../components/Centered";
import Header from "../components/Header";
import Footer from "../components/Footer";

import Launch from "./Launch";
import History from "./History";
import Upcoming from "./Upcoming";

const styles = () => ({
  content: {
    display: "flex",
    flexDirection: "column",
    height: "100vh",
    margin: "auto",
  },
  centered: {
    flex: 1,
    paddingTop: "20px",
    paddingBottom: "10px",
  },
});

const AppLayout = props => {
  const { sounds, classes } = props;

  const [frameVisible, setFrameVisible] = useState(true);
  const animateFrame = () => {
    setFrameVisible(false);
    setTimeout(() => {
      setFrameVisible(true);
    }, 600);
  };

  const onSuccessSound = () => sounds.success && sounds.success.play();
  const onAbortSound = () => sounds.abort && sounds.abort.play();
  const onFailureSound = () => sounds.warning && sounds.warning.play();

  const {
    launches,
    isPendingLaunch,
    submitLaunch,
    abortLaunch,
  } = useLaunches(onSuccessSound, onAbortSound, onFailureSound);

  const planets = usePlanets();
  
  return <div className={classes.content}>
    <Header onNav={animateFrame} />
    <Centered className={classes.centered}>
      <Frame animate 
        show={frameVisible} 
        corners={4} 
        style={{visibility: frameVisible ? "visible" : "hidden"}}>
        {anim => (
          <div style={{padding: "20px"}}>
          <Switch>
            <Route exact path="/">
              <Launch 
                entered={anim.entered}
                planets={planets}
                submitLaunch={submitLaunch}
                isPendingLaunch={isPendingLaunch} />
            </Route>
            <Route exact path="/launch">
              <Launch
                entered={anim.entered}
                planets={planets}
                submitLaunch={submitLaunch}
                isPendingLaunch={isPendingLaunch} />
            </Route>
            <Route exact path="/upcoming">
              <Upcoming
                entered={anim.entered}
                launches={launches}
                abortLaunch={abortLaunch} />
            </Route>
            <Route exact path="/history">
              <History entered={anim.entered} launches={launches} />
            </Route>
          </Switch>
          </div>
        )}
      </Frame>
    </Centered>
    <Footer />
  </div>;
};

export default withSounds()(withStyles(styles)(AppLayout));

pages/History.js

import { useMemo } from "react";
import { Appear, Table, Paragraph } from "arwes";

const History = props => {
  const tableBody = useMemo(() => {
    return props.launches?.filter((launch) => !launch.upcoming)
      .map((launch) => {
        return <tr key={String(launch.flightNumber)}>
          <td>
            <span style={
              {color: launch.success ? "greenyellow" : "red"}
            }></span>
          </td>
          <td>{launch.flightNumber}</td>
          <td>{new Date(launch.launchDate).toDateString()}</td>
          <td>{launch.mission}</td>
          <td>{launch.rocket}</td>
          <td>{launch.customers?.join(", ")}</td>
        </tr>;
      });
  }, [props.launches]);

  return <article id="history">
    <Appear animate show={props.entered}>
      <Paragraph>History of mission launches including SpaceX launches starting from the year 2006.</Paragraph>
      <Table animate>
        <table style={{tableLayout: "fixed"}}>
          <thead>
            <tr>
              <th style={{width: "2rem"}}></th>
              <th style={{width: "3rem"}}>No.</th>
              <th style={{width: "9rem"}}>Date</th>
              <th>Mission</th>
              <th style={{width: "7rem"}}>Rocket</th>
              <th>Customers</th>
            </tr>
          </thead>
          <tbody>
            {tableBody}
          </tbody>
        </table>
      </Table>
    </Appear>
  </article>;
}
  
export default History;

pages/Upcoming.js

import { useMemo } from "react";
import { 
  withStyles,
  Appear,
  Link,
  Paragraph,
  Table,
  Words,
} from "arwes";

import Clickable from "../components/Clickable";

const styles = () => ({
  link: {
    color: "red",
    textDecoration: "none",
  },
});

const Upcoming = props => {
  const { 
    entered,
    launches,
    classes,
    abortLaunch,
  } = props;

  const tableBody = useMemo(() => {
    return launches?.filter((launch) => launch.upcoming)
      .map((launch) => {
        return <tr key={String(launch.flightNumber)}>
          <td>
            <Clickable style={{color:"red"}}>
              <Link className={classes.link} onClick={() => abortLaunch(launch.flightNumber)}>
                ✖
              </Link>
            </Clickable>
          </td>
          <td>{launch.flightNumber}</td>
          <td>{new Date(launch.launchDate).toDateString()}</td>
          <td>{launch.mission}</td>
          <td>{launch.rocket}</td>
          <td>{launch.target}</td>
        </tr>;
      });
  }, [launches, abortLaunch, classes.link]);

  return <Appear id="upcoming" animate show={entered}>
    <Paragraph>Upcoming missions including both SpaceX launches and newly scheduled Zero to Mastery rockets.</Paragraph>
    <Words animate>Warning! Clicking on the ✖ aborts the mission.</Words>
    <Table animate show={entered}>
      <table style={{tableLayout: "fixed"}}>
        <thead>
          <tr>
            <th style={{width: "3rem"}}></th>
            <th style={{width: "3rem"}}>No.</th>
            <th style={{width: "10rem"}}>Date</th>
            <th style={{width: "11rem"}}>Mission</th>
            <th style={{width: "11rem"}}>Rocket</th>
            <th>Destination</th>
          </tr>
        </thead>
        <tbody>
          {tableBody}
        </tbody>
      </table>
    </Table>
  </Appear>;
}

export default withStyles(styles)(Upcoming);

hooks/usePlanets.js

import { useCallback, useEffect, useState } from "react";

import { httpGetPlanets } from "./requests";

function usePlanets() {
  const [planets, savePlanets] = useState([]);

  const getPlanets = useCallback(async () => {
    const fetchedPlanets = await httpGetPlanets();
    savePlanets(fetchedPlanets);
  }, []);

  useEffect(() => {
    getPlanets();
  }, [getPlanets]);

  return planets;
}

export default usePlanets;

其他可以看仓库

server 文件夹下

image.png

app.js

const express = require('express')
const cors = require('cors');
const path = require('path');
const morgan = require('morgan');

const api = require('./routes/api')

const app = express()

// 日志记录位置尽量 早 
app.use(morgan('combined'))

app.use(cors({
    origin: 'http://localhost:3000',
  }));

app.use(express.json())
app.use(express.static(path.join(__dirname, '..', 'public')))

app.use('/v1', api)

// 确保第一页打开就是 index.html 内容
app.get('/*', (req, res) => {
    res.sendFile(path.join(__dirname, '..','public','index.html'));
} )

module.exports = app

server.js

const http = require('http');
require('dotenv').config()

const {mongoConnect} = require('./services/mongo')

const app = require('./app')

const {loadPlanetsData} = require('./models/planets.model')
const {loadLaunchData} = require('./models/launches.model')

const PORT = process.env.PORT || 8000

const server = http.createServer(app)

async function startServer() {
    await mongoConnect()
    await loadPlanetsData()
    await loadLaunchData()

    server.listen(PORT,() => {
        console.log(`Listening on ${PORT}`);
    });
}

startServer()

models/launches.model.js

const axios = require('axios');
const launchesDatabase = require('./launches.mongo')
const planets = require('./planets.mongo')

const DEFAULT_FLIGHT_NUMBER = 100

const launch = {
    flightNumber: 100, // flight_number
    mission: 'Kepler Exploration X', // name
    rocket: 'Explorer IS1', // rocket.name
    launchDate: new Date('December 27, 2030'), // date_local
    target: 'Kepler-442 b', // not applicable
    customers:['ZTM','NASA'],  // payloads.customers for each payload
    upcoming:true, // upcoming
    success: true // success
}

saveLaunch(launch)

async function findLaunch(filter){
    return await launchesDatabase.findOne(filter)
}

async function existsLaunchWithId(launchId) {
    return await launchesDatabase.findOne({
        flightNumber: launchId
    })
}

async function getLatestFlightNumber(){
    // findOne()用于查找匹配查询条件的第一条记录
    // sort('-flightNumber')用于按照flightNumber字段降序排列结果
    const latestLaunch = await launchesDatabase.findOne().sort('-flightNumber')

    if(!latestLaunch) return DEFAULT_FLIGHT_NUMBER

    return latestLaunch.flightNumber
}

async function getAllLaunches(skip, limit) { 
    return await launchesDatabase
    .find(
        {},{
            "_id":0,
            "__v":0
    })
    .skip(skip)
    .limit(limit)
}

async function saveLaunch(launch) {
    await launchesDatabase.findOneAndUpdate({
        flightNumber: launch.flightNumber,
    }, launch, {
        upsert: true,
    })
}

async function scheduleNewLaunch(launch) {
    const planet =  await planets.findOne({
        keplerName: launch.target
    })

    if(!planet){
        throw new Error('Not matching planet found')
    }


    const newFlightNumber = await getLatestFlightNumber() + 1

    const newLaunch = Object.assign(launch, {
        success: true,
        upcoming: true,
        customers:['ZTM','NASA'],
        flightNumber: newFlightNumber
    })

    await saveLaunch(newLaunch)
}

async function abortLaunchById(launchId) {
    const aborted =  await launchesDatabase.updateOne({
        flightNumber: launchId
    },{
        upcoming: false,
        success: false,
    })

    return aborted.modifiedCount === 1
    // const aborted = launches.get(launchId)
    // aborted.success = false
    // aborted.upcoming = false
    // return aborted
}

async function populateLaunches(){
    const response = await axios.post(SPACEX_API_URL,{
        query: {},
        options:{
            // 不分页 拿到所有数据
            pagination:false,
            populate:[
                {
                    path: 'rocket',
                    select:{
                        name:1
                    }
                },
                {
                    path: 'payloads',
                    select:{
                        customers:1
                    }
                }
            ]
        }
    })

    const launchDocs = response.data.docs 
    for(const launchDoc of launchDocs){
        const payloads = launchDoc.payloads 
        // 使用 flatMap 将嵌套数组扁平化
        const customers = payloads.flatMap(payload => payload.customers)

        const launch = {
            flightNumber: launchDoc.flight_number,
            mission:launchDoc.name,
            rocket: launchDoc.rocket.name,
            launchDate: launchDoc.date_local,
            customers,
            upcoming: launchDoc.upcoming,
            success: launchDoc.success
        }

        // console.log('launch',`${launch.flightNumber} ${launch.mission}`);
        await saveLaunch(launch);
    }

    if(response.status !== 200) {
        console.log('Problem downloading launch data');
    }
}

const SPACEX_API_URL = 'https://api.spacexdata.com/v4/launches/query'

async function loadLaunchData(){
    const firstLaunch = await findLaunch({
        flightNumber:1,
        rocket:'Falcon 1',
        mission:'FalconSat'
    })

    if(firstLaunch){
        console.log('Launch data already loaded');
    }else{
        await populateLaunches()
    }


}

module.exports = {
    getAllLaunches,
    scheduleNewLaunch,
    existsLaunchWithId,
    abortLaunchById,
    loadLaunchData,
}

routes/launches/launches.router.js

const express = require('express');

const {httpGetAllLaunch, httpAddLaunch, httpAbortLaunch} = require('./launches.controller')

const  launchesRouter = express.Router();

launchesRouter.get('/', httpGetAllLaunch);
launchesRouter.post('/', httpAddLaunch);
launchesRouter.delete('/:id', httpAbortLaunch);

module.exports = launchesRouter;

仓库

github.com/huanhunmao/…

【翻译】理解 React 的 useEffectEvent:解决过期闭包的完整指南

原文链接: peterkellner.net/2026/01/09/…

作者:Peter Kellner

TL;DR

useEffectEvent 允许你在 Effect 中读取最新的 props/state,而无需将其添加到依赖数组中。当你需要一个始终能看到当前值的稳定回调函数时,它消除了使用 useRef 变通方案的必要性。跳转至对比说明


# 引言 若你曾长期使用 [React](https://react.dev/) 钩子,想必遭遇过这种令人沮丧的场景:在 `useEffect` 中设置订阅或定时器时,回调函数需要读取最新状态。但若将该状态加入依赖数组,每次状态变更时效果器都会重新运行(并重新订阅)。这种做法轻则造成资源浪费,重则导致功能失效。

传统解决方法?将状态镜像到 useRef 中,使回调无需添加依赖即可读取。虽然可行,但冗余代码多且易出错。

React 19.2 推出的 useEffectEvent 提供了优雅解决方案。该钩子创建稳定函数,调用时始终读取最新值——无需在 Effect 依赖中显式添加这些值。

本文将带您了解:

  1. useEffectEvent解决的核心问题
  2. 旧版useRef变通方案及其缺陷
  3. useEffectEvent的底层工作原理
  4. 两种方案的实战示例
  5. 关键规则与注意事项

问题:效果中的陈旧闭包

让我们从一个具体问题开始。你正在构建一个连接聊天室的聊天应用。当收到消息时,你希望显示通知——但仅当通知功能已启用时才显示。

以下是看似"显而易见"却行不通的方法:

import { useEffect, useState } from "react";

function ChatRoom({ roomId }: { roomId: string }) {
  const [notificationsEnabled, setNotificationsEnabled] = useState(true);

  useEffect(() => {
    const connection = connectToRoom(roomId);

    connection.on("message", (message: string) => {
      // BUG: This captures the initial value of notificationsEnabled
      // It will NEVER see updates when the user toggles the checkbox!
      if (notificationsEnabled) {
        showNotification(message);
      }
    });

    return () => connection.disconnect();
  }, [roomId]); // notificationsEnabled is NOT in deps

  return (
    <label>
      <input
        type="checkbox"
        checked={notificationsEnabled}
        onChange={(e) => setNotificationsEnabled(e.target.checked)}
      />
      Enable notifications
    </label>
  );
}

connection.on("message", ...) 中的回调函数创建了一个闭包,该闭包捕获了效果运行时的 notificationsEnabled 值。由于 notificationsEnabled 未包含在依赖数组中,因此当 roomId 发生变化时,该效果仅会触发一次。回调函数将永远看到原始值。

制造新问题的“修复方案”

你可能会想:“简单,只要在依赖项中添加notificationsEnabled就行了!”

useEffect(() => {
  const connection = connectToRoom(roomId);

  connection.on("message", (message: string) => {
    if (notificationsEnabled) {
      showNotification(message);
    }
  });

  return () => connection.disconnect();
}, [roomId, notificationsEnabled]); // Now notificationsEnabled is a dep

现在回调函数能看到最新值了……但有个问题。每次 notificationsEnabled 改变时,效果都会重新运行。这意味着:

  1. 断开房间连接
  2. 重新连接房间
  3. 重新注册消息处理器

切换通知复选框不该导致聊天重新连接!这将导致糟糕的用户体验——重新连接期间可能遗漏消息,服务器连接数激增,纯属资源浪费。

这正是 useEffectEvent 解决的核心矛盾:某些值应触发 Effect 重跑(如roomId,而另一些值仅需在需要时读取但不触发重跑(如notificationsEnabled

旧式解决方法:useRef

useEffectEvent出现之前,标准做法是通过将值镜像到useRef中来"逃逸"闭包:

import { useEffect, useRef, useState } from "react";

function ChatRoom({ roomId }: { roomId: string }) {
  const [notificationsEnabled, setNotificationsEnabled] = useState(true);

  // Mirror the value into a ref
  const notificationsEnabledRef = useRef(notificationsEnabled);

  // Keep the ref in sync with state
  useEffect(() => {
    notificationsEnabledRef.current = notificationsEnabled;
  }, [notificationsEnabled]);

  useEffect(() => {
    const connection = connectToRoom(roomId);

    connection.on("message", (message: string) => {
      // Read from the ref instead of the closure
      if (notificationsEnabledRef.current) {
        showNotification(message);
      }
    });

    return () => connection.disconnect();
  }, [roomId]); // Only roomId triggers reconnection

  return (
    <label>
      <input
        type="checkbox"
        checked={notificationsEnabled}
        onChange={(e) => setNotificationsEnabled(e.target.checked)}
      />
      Enable notifications
    </label>
  );
}

这确实有效!连接效果仅在roomId变更时重新运行。消息处理器从notificationsEnabledRef.current读取数据,该值始终保持最新状态。

useRef模式存在的问题

  1. 冗余代码:每次需要"逃逸"的值都需创建ref、同步效果,并记得读取.current
  2. 易遗漏:新增回调所需值时,必须额外添加 ref 和同步 Effect
  3. 组件冗余:核心逻辑被 ref 管理层掩盖
  4. 无法通过代码检查:ESLint 钩子规则无法验证 ref 使用正确性

使用useEffectEvent

useEffectEvent提供了一种一流的解决方案。它返回一个稳定函数,调用时始终使用最新的propsstate执行。

import { useEffect, useState, useEffectEvent } from "react";

function ChatRoom({ roomId }: { roomId: string }) {
  const [notificationsEnabled, setNotificationsEnabled] = useState(true);

  // Create an Effect Event that reads latest values
  const onMessage = useEffectEvent((message: string) => {
    if (notificationsEnabled) {
      showNotification(message);
    }
  });

  useEffect(() => {
    const connection = connectToRoom(roomId);
    connection.on("message", onMessage);
    return () => connection.disconnect();
  }, [roomId]); // Only roomId triggers reconnection

  return (
    <label>
      <input
        type="checkbox"
        checked={notificationsEnabled}
        onChange={(e) => setNotificationsEnabled(e.target.checked)}
      />
      Enable notifications
    </label>
  );
}

请注意以下差异:

  • 无引用
  • 无同步效果
  • .current 读取
  • onMessage 函数保持稳定(在不同渲染间具有相同标识)
  • 但调用时会看到当前 notificationsEnabled 的值

并列对比:顿悟时刻

让我们通过一个实际案例来对比两种方法:使用分析工具追踪页面访问量。

问题描述

您希望在URL变更时记录页面访问,日志应包含:

  • 访问的URL(响应式——应触发日志)
  • 当前购物车商品数量(非响应式——不应触发新日志)

未使用useEffectEvent(useRef替代方案)

import { useContext, useEffect, useRef } from "react";
import { ShoppingCartContext } from "./cart";

function Page({ url }: { url: string }) {
  const { items } = useContext(ShoppingCartContext);
  const numberOfItems = items.length;

  // Step 1: Create a ref to hold the latest value
  const numberOfItemsRef = useRef(numberOfItems);

  // Step 2: Keep the ref synchronized
  useEffect(() => {
    numberOfItemsRef.current = numberOfItems;
  }, [numberOfItems]);

  // Step 3: Use the ref in your Effect
  useEffect(() => {
    logVisit(url, numberOfItemsRef.current);
  }, [url]); // Only url triggers re-run
}

解决方法所需代码行数:8(引用声明、同步效果、读取.current

使用useEffectEvent

import { useContext, useEffect, useEffectEvent } from "react";
import { ShoppingCartContext } from "./cart";

function Page({ url }: { url: string }) {
  const { items } = useContext(ShoppingCartContext);
  const numberOfItems = items.length;

  // Create an Effect Event for the non-reactive logic
  const onVisit = useEffectEvent((visitedUrl: string) => {
    logVisit(visitedUrl, numberOfItems);
  });

  useEffect(() => {
    onVisit(url);
  }, [url]); // Only url triggers re-run
}

代码行数:4(仅包含Effect事件和Effect)

关键洞察:参数与捕获值的区别

注意我将url作为参数传递给onVisit(),而非直接在Effect事件内部读取。这是有意为之,且符合React文档的建议。

当将 url 作为参数传递时:

  • 不同 URL 明确代表不同"事件"
  • 响应式值通过函数调用显式传递

当在效果事件内部读取 numberOfItems 时:

  • 它捕获调用时的最新值
  • 不影响效果事件的触发时机

这种模式清晰区分了响应式与非响应式逻辑。

这是魔法吗?(剧透:不,只是JavaScript)

初次见到useEffectEvent时,我的反应是:"等等,这怎么实现的?回调函数居然...能自动获取最新状态?React在搞什么魔法?"

答案是否定的。这里没有魔法,没有特殊编译技巧,也没有隐藏的React内部机制在做不可思议的事。useEffectEvent 实现的正是你手动使用 useRef 时所做的操作——React 只是将这种模式自动化了。

让我们通过图表逐步揭开谜底。

直观理解闭包失效问题

首先可视化闭包失效的原因:当你在组件内部创建回调函数时,它会捕获该次渲染的值: 在渲染1中创建的回调函数捕获了count=0的值。即使组件使用新值重新渲染,该回调函数仍保留其原始闭包。当事件最终触发时,它读取的是过时的值。

useRef如何解决此问题

useRef模式之所以有效,是因为ref提供了一个具有稳定标识的可变容器 关键洞见:ref对象本身永远不会改变身份。回调函数的闭包捕获了ref对象(保持不变),当它运行时,会读取.current(已被更新)。间接引用解决了问题!

useEffectEvent的工作原理(揭秘其"魔力")

现在揭晓答案:useEffectEvent的实现原理完全相同。React创建了一个稳定的封装函数,该函数委托给内部持有最新回调的ref对象: 当你调用useEffectEvent返回的包装函数时:

  • 它不会直接执行你的回调函数
  • 而是从内部引用中查找最新版本
  • 然后调用该最新版本

这就是为什么返回的函数具有稳定的特性(跨渲染的相同函数引用),但读取最新值(因为它委托给刚更新的回调函数)。

等效表达

以下代码实现了相同效果: 使用 useRef 的实现:

// Manual approach: 8 lines
const countRef = useRef(count);
useEffect(() => {
  countRef.current = count;
}, [count]);

const handler = useCallback(() => {
  console.log(countRef.current);
}, []);

useEffectEvent的实现:

// useEffectEvent: 3 lines
const handler = useEffectEvent(() => {
  console.log(count);
});

相同的行为。更少的代码。所谓的“魔法”不过是React自动实现了众所周知的模式。

概念性实现

以下是useEffectEvent在底层的核心工作原理:

// This is NOT the actual React implementation, just a mental model
function useEffectEvent<T extends (...args: any[]) => any>(callback: T): T {
  // This ref holds the latest callback
  const latestCallbackRef = useRef(callback);

  // Update the ref after each render (synchronously, before Effects run)
  latestCallbackRef.current = callback;

  // Return a stable wrapper that calls the latest callback
  const stableWrapper = useCallback((...args: Parameters<T>) => {
    return latestCallbackRef.current(...args);
  }, []);

  return stableWrapper as T;
}

实际实现更为复杂(它与React内部的Fiber架构深度集成),但这抓住了核心本质:一个稳定的标识符包裹着一个持续更新的回调函数

为何这很重要

理解 useEffectEvent 并非魔法具有实际益处:

  1. **调试:**当出错时,你能理性分析——它本质只是引用和回调
  2. **思维模型:**你理解规则存在的缘由(如"仅限从效果器调用")
  3. **回退知识:**在旧版 React 中,你清楚如何复现行为
  4. **信心:**你不再依赖"它就是管用"——而是理解其机制

最优秀的抽象并非神秘的黑盒,而是对已知模式的便捷封装。

更复杂的示例:静音聊天连接

以下示例充分展现了其价值。设想一款聊天应用:

  • 切换聊天室时应重新连接
  • 切换静音状态时不应重新连接

显而易见的笨拙方法

function Chat({ roomId }: { roomId: string }) {
  const [isMuted, setIsMuted] = useState(false);

  useEffect(() => {
    const connection = connectToRoom(roomId);

    connection.on("message", (message: string) => {
      // BUG: isMuted is stale!
      if (!isMuted) {
        playMessageSound();
      }
      addMessageToChat(message);
    });

    return () => connection.disconnect();
  }, [roomId]); // isMuted not in deps = stale

  return (
    <button onClick={() => setIsMuted(!isMuted)}>
      {isMuted ? "Unmute" : "Mute"}
    </button>
  );
}

随心切换静音状态——声音播放却基于初始的isMuted值。

重新连接的“修复”

useEffect(() => {
  const connection = connectToRoom(roomId);

  connection.on("message", (message: string) => {
    if (!isMuted) {
      playMessageSound();
    }
    addMessageToChat(message);
  });

  return () => connection.disconnect();
}, [roomId, isMuted]); // Now it works... but reconnects on mute toggle

此方法可行但用户体验极差。每次静音切换都会:

  1. 断开聊天连接
  2. 重新连接
  3. 可能在重新连接期间遗漏消息

useRef 替代方案

function Chat({ roomId }: { roomId: string }) {
  const [isMuted, setIsMuted] = useState(false);

  const isMutedRef = useRef(isMuted);
  useEffect(() => {
    isMutedRef.current = isMuted;
  }, [isMuted]);

  useEffect(() => {
    const connection = connectToRoom(roomId);

    connection.on("message", (message: string) => {
      if (!isMutedRef.current) {
        playMessageSound();
      }
      addMessageToChat(message);
    });

    return () => connection.disconnect();
  }, [roomId]);

  return (
    <button onClick={() => setIsMuted(!isMuted)}>
      {isMuted ? "Unmute" : "Mute"}
    </button>
  );
}

运行正常,但会添加引用模板代码。

Clean useEffectEvent 解决方案

import { useEffect, useState, useEffectEvent } from "react";

function Chat({ roomId }: { roomId: string }) {
  const [isMuted, setIsMuted] = useState(false);

  const onMessage = useEffectEvent((message: string) => {
    if (!isMuted) {
      playMessageSound();
    }
    addMessageToChat(message);
  });

  useEffect(() => {
    const connection = connectToRoom(roomId);
    connection.on("message", onMessage);
    return () => connection.disconnect();
  }, [roomId]); // Clean: only roomId triggers reconnection

  return (
    <button onClick={() => setIsMuted(!isMuted)}>
      {isMuted ? "Unmute" : "Mute"}
    </button>
  );
}

无引用、无额外效果、无冗余代码。关注点分离明确:

  • 响应式(roomId): 变更触发重新连接
  • 非响应式(isMuted): 按需读取最新值,不触发重新连接

规则与注意事项

useEffectEvent 功能强大,但需遵循重要规则。eslint-plugin-react-hooks(6.1.1+版本)会强制执行这些规则。

规则一:仅在效果器内部调用效果事件

效果事件的设计目的仅限于在效果器内部调用。它们并非通用型稳定回调函数。

// ✅ Correct: Called from inside an Effect
const onMessage = useEffectEvent((msg: string) => {
  console.log(msg, latestState);
});

useEffect(() => {
  socket.on("message", onMessage);
  return () => socket.off("message", onMessage);
}, []);

// ❌ Wrong: Called from an event handler
<button onClick={() => onMessage("hello")}>
  Click me
</button>

// ❌ Wrong: Called during render
return <div>{onMessage("rendered")}</div>;

对于常规事件处理器(如onClickonChange等),无需使用useEffectEvent。由于处理器在每次渲染时都会创建,因此每次运行时都会获得最新值。

规则二:不要将效果事件传递给其他组件

效果事件应局限于其所属组件内部。请勿将其作为 props 传递:

// ✅ Correct: Keep Effect Events local
function Parent() {
  const [count, setCount] = useState(0);

  const onTick = useEffectEvent(() => {
    console.log(count);
  });

  useEffect(() => {
    const id = setInterval(() => onTick(), 1000);
    return () => clearInterval(id);
  }, []);

  return <div>Count: {count}</div>;
}

// ❌ Wrong: Passing Effect Event as a prop
function Parent() {
  const onTick = useEffectEvent(() => {
    console.log(latestCount);
  });

  return <Timer onTick={onTick} />; // Don't do this!
}

若需构建需要回调参数的自定义钩子,请在钩子内部而非外部定义效果事件。

规则三:在使用效果事件之前声明

将效果事件声明置于其使用位置附近:

// ✅ Good: Effect Event declared right before its Effect
function Component() {
  const [value, setValue] = useState(0);

  const onInterval = useEffectEvent(() => {
    console.log("Current value:", value);
  });

  useEffect(() => {
    const id = setInterval(() => onInterval(), 1000);
    return () => clearInterval(id);
  }, []);
}

// ❌ Avoid: Effect Event far from its Effect (confusing)
function Component() {
  const [value, setValue] = useState(0);
  const onInterval = useEffectEvent(() => { /* ... */ });

  // ... 50 lines of other code ...

  useEffect(() => {
    const id = setInterval(() => onInterval(), 1000);
    return () => clearInterval(id);
  }, []);
}

规则4:不要使用useEffectEvent来抑制代码检查器警告

这关乎意图。useEffectEvent用于分离响应式与非响应式逻辑——而非用于屏蔽exhaustive-deps代码检查规则。

// ✅ Correct: page SHOULD be a dependency because you WANT to refetch when it changes
useEffect(() => {
  async function fetchData() {
    const data = await fetch(`/api/items?page=${page}`);
    setItems(data);
  }
  fetchData();
}, [page]); // Correctly triggers refetch on page change

// ❌ Wrong mental model: "I'll use useEffectEvent so I don't have to list dependencies"
const fetchData = useEffectEvent(async () => {
  const data = await fetch(`/api/items?page=${page}`);
  setItems(data);
});

useEffect(() => {
  fetchData();
}, []); // "Now I don't need page in deps!" <- Wrong!

需要思考的问题是:“当该值发生变化时,是否需要重新运行效果?”若答案为是,则属于依赖关系;若答案为否(仅需在其他触发器启动效果时读取最新值),则应使用效果事件。

何时应使用 useEffectEvent

当效果器内部的回调函数满足以下条件时,请使用 useEffectEvent

  • 被传递给订阅器、定时器或外部库,且不希望重新注册
  • 调用时需要读取最新的 props/state
  • 这些值不应触发效果器重新运行

常见场景:

场景 响应式(触发效果) 非响应式(效果事件)
聊天室连接 roomId isMutedtheme
分析日志记录 pageUrl cartItemCountuserId
间隔计数器 - (仅运行一次) countstep
WebSocket消息 socketUrl isOnlinepreferences
动画帧 - (仅运行一次) currentPosition

关于 React 版本说明

useEffectEvent 作为稳定功能在 React 19.2 中引入。若您使用的是早期版本:

  • React 18.x 及更早版本: 请使用上述描述的 useRef 模式
  • React 19.0-19.1:useEffectEvent 可用但处于实验阶段
  • React 19.2+: 可放心使用 useEffectEvent

您可通过以下方式检查 React 版本:

npm list react

总结

useEffectEvent 解决了 React 钩子中长期存在的痛点:在 Effect 内部访问最新状态/属性时,避免触发不必要的重新运行。

之前: 将值镜像到 ref 中,添加同步 Effect,读取 .current——所有这些都是手动操作且易出错的冗余代码。

之后: 将回调函数包裹在 useEffectEvent 中,让 React 自动保持其更新。

核心思维模型:

  • 依赖关系回答:"何时应重新运行此 Effect?"
  • Effect Events回答:"Effect 运行时应读取哪些值?"

通过明确分离这些关注点,代码更清晰,更不易引入错误。

延伸阅读

Vue3 多主题/明暗模式切换:CSS 变量 + class 覆盖的完整工程方案(附开源代码)

文章简介

之前逛 V 站的时候刷到一个讲 JSON 格式化工具信息泄漏的帖子,有条评论说:“V 站不是人手一个工具站吗?”受此感召,我给自己做了一个工具站。

在搭建工具站的时候有做多主题、亮/暗主题切换,于是有了这篇文章。

备注:工具站当前支持的工具还不多,但已开源,也有部署在 Github page 中,文中介绍的主题切换源码也在其中,感兴趣的朋友可随意取用,后续我也会将自己要用的、感兴趣的工具集成进去。

再备注:此处介绍的多主题、模式切换是在 vue3 中实现,其他环境请感兴趣的朋友自行实现。

工具站源码地址

仓库地址:github.com/the-wind-is…

工具站地址:the-wind-is-rising-dev.github.io/endless-que…

实现原理

主题切换使用了 CSS 变量和 class 覆盖两种特性。

  • class 覆盖特性,后加载的 class 样式会覆盖之前加载的 class 样式,变量也会被覆盖。
  • CSS 变量定义时以 -- 开头,如下:
:root {
  /* ========== 品牌主色调 ========== */
  --brand-primary: #4f46e5; /* 主色:靛蓝 */
  --brand-secondary: #0ea5e9; /* 次要色:天蓝 */
  --brand-accent: #8b5cf6; /* 强调色:紫色 */
}

实现思路

  1. 首先在 :root 伪 class 下定义所有需要用到的变量,然后定义拥有相同变量的不同主题 class
  2. 切换主题时通过 document 直接设置对应主题的 class
  3. 跟随系统主题可以通过监听 (prefers-color-scheme: dark) 来切换

:root 伪 class 定义

源码在 src/themes/index.css 文件内,此处只贴出部分变量

:root {
  /* 背景与表面色 */
  --bg-primary: #f8fafc; /* 主背景 */
  --bg-secondary: #ffffff; /* 次级背景/卡片 */
  --bg-tertiary: #f1f5f9; /* 工具栏/三级背景 */
  --bg-sidebar: #e2e8f0; /* 侧边栏背景 */
}

默认主题明亮模式 class 定义

源码在 src/themes/default/light.css 文件内,此处只贴出部分变量

html.theme-default {
  /* 背景与表面色 */
  --bg-primary: #f8fafc; /* 主背景 */
  --bg-secondary: #ffffff; /* 次级背景/卡片 */
  --bg-tertiary: #f1f5f9; /* 工具栏/三级背景 */
  --bg-sidebar: #e2e8f0; /* 侧边栏背景 */
}

默认主题暗夜模式 class 定义

源码在 src/themes/default/dark.css 文件内,此处只贴出部分变量

html.theme-default.dark {
  /* 背景与表面色 */
  --bg-primary: #0f172a; /* 主背景 */
  --bg-secondary: #1e293b; /* 次级背景/卡片 */
  --bg-tertiary: #334155; /* 工具栏/三级背景 */
  --bg-sidebar: #1e293b; /* 侧边栏背景 */
}

主题切换源码

源码位置:src/themes/theme.ts

切换主题后会将当前主题保存至本地,下次打开站点时会自动加载上次设置的主题

  • 对象定义
    • Theme:用来定义主题信息
    • ThemeModel:用来定义当前模式(明亮/暗夜),以及是否跟随系统
    • ThemeConfig:用来定义当前主题与模式
  • 函数定义
    • isDarkMode:用来判断当前系统是否为暗夜模式
    • applyTheme:用来应用主题与模式
    • initializeTheme:初始化主题,用来加载之前设置的主题与模式
    • getCurrentThemeConfig:获取当前主题配置(主题与模式)
    • addDarkListener:添加暗夜模式监听
    • removeDarkListener:移除暗夜模式监听
    • changeThemeMode:切换主题模式(亮/暗模式)
    • changeTheme:切换主题,默认主题、星空主题、海洋主题等
    • getThemeList:获取支持的主题列表 备注:主题初始化、暗夜模式监听/移除监听函数需要在主页面加载时调用、设置
// 存储主题配置的键
const THEME_STORAGE_KEY = "custom-theme";

// 主题
export interface Theme {
  name: string; // 主题名称
  className: string; // 对应的 CSS 类名
}

// 模式
export interface ThemeModel {
  name: string; // 模式名称
  followSystem: boolean; // 是否跟随系统
  value: "light" | "dark"; // 模式值
}

// 主题配置
export interface ThemeConfig {
  theme: Theme; // 主题
  model: ThemeModel; // 默认主题模式
}

/**
 * 检测当前系统是否启用暗黑模式
 */
function isDarkMode() {
  return (
    window.matchMedia &&
    window.matchMedia("(prefers-color-scheme: dark)").matches
  );
}

/**
 * 应用主题
 * @param themeConfig 主题配置
 */
function applyTheme(themeConfig: ThemeConfig) {
  const className = themeConfig.theme.className;
  const mode = themeConfig.model;

  // 移除旧的主题类
  const classes = document.documentElement.className.split(" ");
  const themeClasses = classes.filter(
    (c) => !c.includes("theme-") && c !== "dark"
  );
  document.documentElement.className = themeClasses.join(" ");

  // 添加新的主题类
  document.documentElement.classList.add(className);
  // 判断是否启用暗黑模式
  if (mode.value === "dark") {
    document.documentElement.classList.add("dark");
  }

  // 存储当前主题配置
  localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(themeConfig));
}

/**
 * 初始化主题
 */
export function initializeTheme() {
  // 获取当前主题配置并应用
  const themeConfig = getCurrentThemeConfig();
  // 初始化当前主题类型
  if (themeConfig.model.followSystem) {
    themeConfig.model.value = isDarkMode() ? "dark" : "light";
  }
  applyTheme(themeConfig);
}

/**
 * 获取当前主题配置
 * @returns 主题配置
 */
export function getCurrentThemeConfig(): ThemeConfig {
  let theme: any = localStorage.getItem(THEME_STORAGE_KEY);
  return theme
    ? JSON.parse(theme)
    : {
        theme: getThemeList()[0], // 默认主题
        model: {
          name: "跟随系统",
          followSystem: true,
          value: isDarkMode() ? "dark" : "light",
        },
      };
}

/**
 * 添加暗黑模式监听
 */
export function addDarkListener() {
  // 监听暗黑模式变化, auto 模式动态切换主题
  window
    .matchMedia("(prefers-color-scheme: dark)")
    .addEventListener("change", (e) => {
      const themeConfig = getCurrentThemeConfig();
      if (!themeConfig.model.followSystem) return;
      changeThemeMode(themeConfig.model);
    });
}

/**
 * 移除暗黑模式监听
 */
export function removeDarkListener() {
  window
    .matchMedia("(prefers-color-scheme: dark)")
    .removeEventListener("change", () => {});
}

/**
 * 切换主题模式
 * @param mode 模式
 */
export function changeThemeMode(themeModel: ThemeModel) {
  const themeConfig = getCurrentThemeConfig();
  themeConfig.model = themeModel;
  if (themeModel.followSystem) {
    themeConfig.model.value = isDarkMode() ? "dark" : "light";
  }
  applyTheme(themeConfig);
}

/**
 * 切换主题
 * @param theme 主题
 */
export function changeTheme(theme: Theme) {
  const themeConfig = getCurrentThemeConfig();
  themeConfig.theme = theme;
  applyTheme(themeConfig);
}

/**
 * 获取主题列表
 * @returns 主题列表
 */
export function getThemeList(): Theme[] {
  return [
    {
      name: "默认",
      className: "theme-default",
    },
    {
      name: "星空",
      className: "theme-starry",
    },
    {
      name: "海洋",
      className: "theme-ocean",
    },
  ];
}

主题、模式手动切换组件

源码位置:src/themes/Theme.vue

组件内会自动加载站点支持的主题与模式,也会根据系统模式变化自动切换状态信息,源码内有注释,此处不赘述

<script setup lang="ts">
import { SettingOutlined, BulbFilled } from "@ant-design/icons-vue";
import { onMounted, onUnmounted, ref } from "vue";
import {
  Theme,
  getThemeList,
  getCurrentThemeConfig,
  changeTheme,
  changeThemeMode,
} from "./theme";

const themeList = ref<Theme[]>(getThemeList());
const currentTheme = ref<Theme>(getCurrentThemeConfig().theme);
const followSystem = ref<boolean>(getCurrentThemeConfig().model.followSystem);
const isLightModel = ref<boolean>(
  getCurrentThemeConfig().model.value == "light"
);

// 切换主题
function onChangeTheme(theme: Theme) {
  currentTheme.value = theme;
  changeTheme(theme);
}

// 切换跟随系统
function onFollowSystemChange() {
  followSystem.value = !followSystem.value;
  let themeConfig = getCurrentThemeConfig();
  themeConfig.model.followSystem = followSystem.value;
  changeThemeMode(themeConfig.model);
}

// 切换主题模式
function onChangeThemeModel(value: boolean) {
  isLightModel.value = value;
  let themeConfig = getCurrentThemeConfig();
  themeConfig.model.value = value ? "light" : "dark";
  changeThemeMode(themeConfig.model);
}

// 添加主题模式监听
let interval: NodeJS.Timeout | null = null;
onMounted(() => {
  // 定时更新主题信息
  interval = setInterval(() => {
    const themeConfig = getCurrentThemeConfig();
    currentTheme.value = themeConfig.theme;
    followSystem.value = themeConfig.model.followSystem;
    isLightModel.value = themeConfig.model.value == "light";
  }, 200);
});

onUnmounted(() => {
  // 移除定时更新主题信息
  interval && clearInterval(interval);
});
</script>

<template>
  <div class="theme-root center">
    <a-dropdown placement="bottom">
      <div class="theme-btn center">
        <SettingOutlined />
      </div>
      <template #overlay>
        <a-menu>
          <div
            class="theme-item"
            v-for="theme in themeList"
            :key="theme.className"
            @click="onChangeTheme(theme)"
          >
            <div class="row">
              <div
                style="width: var(--space-xl); font-size: var(--font-size-sm)"
              >
                <BulbFilled
                  class="sign"
                  v-if="theme.className == currentTheme.className"
                />
              </div>
              <div>{{ theme.name }}-主题</div>
            </div>
          </div>
          <div class="theme-model-item row">
            <a-radio
              v-model:checked="followSystem"
              @click="onFollowSystemChange()"
              >🖥️</a-radio
            >
            <a-switch
              checked-children="☀️"
              un-checked-children="🌑"
              v-model:checked="isLightModel"
              :disabled="followSystem"
              @change="onChangeThemeModel"
            />
          </div>
        </a-menu>
      </template>
    </a-dropdown>
  </div>
</template>

<style scoped>
.theme-root {
  padding: var(--space-lg);
}
.theme-btn {
  padding: var(--space-xs) var(--space-lg);
  font-size: var(--font-size-2xl);
  color: var(--brand-primary);
}
.theme-item {
  padding: var(--space-sm) var(--space-md);
  border-radius: var(--radius-sm);
  color: var(--text-primary);
  user-select: none;
  cursor: pointer;

  .sign {
    color: var(--brand-accent);
  }

  &:hover {
    background: var(--brand-secondary);
    color: var(--text-inverse);
  }

  &:active {
    background: var(--brand-primary);
    color: var(--text-inverse);
    .sign {
      color: var(--text-inverse);
    }
  }
}
.theme-model-item {
  padding: var(--space-sm) var(--space-md);
  color: var(--text-primary);
  user-select: none;
}
</style>

vue main.js 文件内容

源码位置:src/main.js

该文件内需引入 "src/themes/index.css" 文件,如下

import { createApp } from "vue";
import Antd from "ant-design-vue";
import "./themes/index.css";
import App from "./App.vue";

createApp(App).use(Antd).mount("#app");

主题初始化、模式监听

源码位置:src/App.vue

src/App.vue 文件是 vue 所有的页面基础,在此处初始化主题信息、监听模式变化比较合适。

  • 初始化主题样式只需要调用 src/themes/theme.ts 内的 initializeTheme() 函数即可
  • 监听模式变化需要在组件挂载之后,在 onMounted 函数内调用 addDarkListener() 函数即可
  • 移除监听需要在组件卸载之后,在 onUnmounted 函数内调用 removeDarkListener() 函数即可

src/App.vue 文件内 script 块部分源码如下

function initialize() {
  // 初始化主题样式
  initializeTheme();
}
initialize();
// 组件生命周期钩子
onMounted(() => {
  initialize();
  // 添加暗黑模式监听器
  addDarkListener();
});
onUnmounted(() => {
  // 移除暗黑模式监听器
  removeDarkListener();
});

仓库地址:

仓库地址:github.com/the-wind-is…

工具站地址:the-wind-is-rising-dev.github.io/endless-que…

❌