普通视图

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

React-彻底搞懂 Redux:从单向数据流到 useReducer 的终极抉择

2026年3月23日 21:42

前言

在 React 生态中,状态管理一直是开发者绕不开的话题。Redux 以其严谨的“单向数据流”闻名,虽然有一定的学习成本,但它为大型项目带来的可预测性和可调试性是无可替代的。本文将带你深度复盘 Redux 的核心逻辑。

一、 Redux 的核心

Redux 的核心思想是将应用的所有状态(State)集中存储在一个唯一的 Store 中,并遵循严格的规则进行更新,让状态变化可追踪、可调试,大幅降低复杂应用的状态维护成本。

1. 三大核心概念

Redux 的运行逻辑完全围绕三大核心模块展开,各司其职、互不干扰,构建了清晰的单向数据流:

1. Store(数据仓库)

  • 定位:应用状态的唯一存储容器,整个应用有且仅有一个 Store
  • 作用:承载全局状态、派发 Action、监听状态变化、整合 Reducer,是连接视图和数据的核心枢纽
  • 特性:独立于组件生命周期,不会随组件销毁而消失,状态持久稳定

2. Action(动作描述)

  • 定位改变 State 的唯一途径,是一个普通的 JavaScript 对象
  • 结构:必须包含 type 属性(字符串类型,描述动作类型),可选携带 payload 属性(传递更新状态所需的数据)
  • 示例{ type: 'UPDATE_USER_NAME', payload: '李四' }
  • 本质:只描述“要做什么”,不负责“怎么做”,属于指令载体

3. Reducer(状态处理器)

  • 定位纯函数(固定输入必然得到固定输出,无副作用、不修改入参)
  • 参数:接收两个参数——当前旧 State(prevState)、派发的 Action 对象
  • 逻辑:根据 Action 的 type 类型,匹配对应的更新逻辑,绝不直接修改旧 State
  • 规则:Redux 强制要求 State 不可变(Immutable),必须返回全新的 State 对象,保证状态变化可回溯、可调试

二、 Redux 的工作流程:闭环的单向流

Redux 的数据流转遵循严格的循环路径,确保了状态变化的可追踪性:

  1. 用户触发操作:用户在页面执行交互行为(点击按钮、输入内容、路由跳转等),组件内触发状态更新需求

  2. 派发 Action:通过 Redux 提供的 dispatch 方法,将封装好的 Action 对象派发出去

  3. Reducer 处理:Store 自动将当前旧 State 和派发的 Action 传递给 Reducer,Reducer 根据 type 执行对应逻辑,返回新 State

  4. Store 更新状态:Store 接收 Reducer 返回的新 State,替换内部旧状态

  5. 组件同步数据:所有订阅了 Store 状态的组件,会自动感知状态变化,重新渲染视图,完成数据同步

注意:禁止直接修改 Store 中的 State,必须通过 dispatch 派发 Action → Reducer 生成新 State 的方式更新,这是 Redux 可预测性的核心保障。


三、 Redux vs useReducer:我该选哪个?

很多开发者会混淆这两者,虽然它们都使用了 action/reducer 模式,但在应用范围上有本质区别。

维度 useReducer Redux
存储位置 组件内部(Local State) 独立的全局 Store(Global State)
作用域 仅限当前组件及其子组件 整个应用,任意组件均可访问
生命周期 随组件销毁而消失 独立于组件,持久存在
跨组件通信 需配合 useContext 并提升组件层级 天然支持,无需透传 Props

场景选择:

  • 使用 useReducer:逻辑复杂(有很多 if/else 或 switch),但只在单个组件或其嵌套子组件中使用。
  • 使用 Redux:数据需要全局共享。例如:用户信息需要同步更新导航栏、侧边栏和个人中心;或者需要将状态持久化到 localStorage 并在刷新后恢复。

四、 总结

Redux 的本质是牺牲了一定的代码简便性,换取了极致的状态可预测性。在处理跨页面同步、复杂业务逻辑以及需要状态回溯的场景下,Redux 依然是前端状态管理的王者。

别再瞎写了!Cesium 模型 360° 环绕,4 套源码全公开,项目直接用

作者 李剑一
2026年3月23日 21:12

之前在地图上展示的园区模型增加了选中效果,但是对于展示性质的大屏而言,内容一直是静态展示的效果肯定不好。

image.png

所以模型自动环绕展示是绝对的核心亮点功能!无论是展厅大屏演示、项目汇报、还是产品展示,流畅的360°环绕浏览都能让你的三维场景瞬间提升质感。

简单整理了4种开箱即用的环绕方案,从极简入门到专业级平滑动画全覆盖,适配不同项目需求,复制代码直接运行!

flyToLocation + 定时器(极简)

利用之前封装 cesium-viewer 组件做的 flyToLocation 方法实现此功能。

之前封装的 flyToLocation 方法其实就是 cesium.camera.flyTo 方法。

这个方案代码最简单,无需复杂数学计算。支持分段式环绕,适合快速实现需求

可控制停留时间、飞行速度、环绕点数。

image.png

完整代码

// 环绕展示工厂模型
const displayFactoryModel = () => {
    if (!cesiumViewRef.value) return;
    
    const centerLon = 117.104273; // 模型中心点经度
    const centerLat = 36.437867; // 模型中心点纬度
    const radius = 150; // 环绕半径(米)
    const height = 80; // 相机高度
    const duration = 2; // 每个角度飞行时间(秒)
    
    let currentAngle = 0;
    const totalAngles = 8; // 环绕8个点
    const angleStep = 360 / totalAngles;
    
    const flyToNextPoint = () => {
        // 计算当前角度的位置
        const rad = currentAngle * Math.PI / 180;
        const offsetX = radius * Math.cos(rad);
        const offsetY = radius * Math.sin(rad);
        
        // 计算新的经纬度(简化计算,适用于小范围)
        const newLon = centerLon + (offsetX / 111320 / Math.cos(centerLat * Math.PI / 180));
        const newLat = centerLat + (offsetY / 111320);
        
        // 计算相机朝向(朝向中心点)
        const heading = (currentAngle + 180) % 360;
        
        cesiumViewRef.value.flyToLocation({
            longitude: newLon,
            latitude: newLat,
            height: height,
            duration: duration,
            heading: heading,
            pitch: -30, // 稍微俯视的角度
            onComplete: () => {
                currentAngle = (currentAngle + angleStep) % 360;
                setTimeout(flyToNextPoint, 500); // 短暂停留后继续
            }
        });
    };
    
    flyToNextPoint();
};

lookAt + 帧动画(推荐)

这个方案相较于上面的方案比较好的一点就是真正的360°无缝平滑环绕

这种方案无卡顿、无跳跃,动画效果最自然,并且支持无限循环环绕。生产环境推荐首选方案

完整代码

// 环绕展示工厂模型
const displayFactoryModel = () => {
    if (!cesiumViewer.value) return;
    
    const center = Cesium.Cartesian3.fromDegrees(117.104273, 36.437867, 0);
    const radius = 150; // 环绕半径
    const height = 80; // 相机高度
    const duration = 20; // 完整环绕一周的时间(秒)
    
    let startTime = null;
    let animationId = null;
    
    const animateOrbit = (timestamp) => {
        if (!startTime) startTime = timestamp;
        const elapsed = (timestamp - startTime) / 1000;
        
        // 计算当前角度(0到2π)
        const angle = (elapsed / duration) * 2 * Math.PI;
        
        // 计算相机位置(圆形轨道)
        const cameraX = center.x + radius * Math.cos(angle);
        const cameraY = center.y + radius * Math.sin(angle);
        const cameraZ = center.z + height;
        const cameraPosition = new Cesium.Cartesian3(cameraX, cameraY, cameraZ);
        
        // 设置相机位置和朝向(看向中心点)
        cesiumViewer.value.camera.lookAt(
            cameraPosition,
            center, // 看向中心点
            new Cesium.Cartesian3(0, 0, 1)  // up方向
        );
        
        // 继续动画
        animationId = requestAnimationFrame(animateOrbit);
    };
    
    // 开始动画
    animationId = requestAnimationFrame(animateOrbit);
    
    // 返回停止函数(可选)
    return () => {
        if (animationId) {
            cancelAnimationFrame(animationId);
        }
    };
};

flyTo + 曲线路径

基于样条曲线生成平滑路径。支持自定义轨迹点、飞行总时长。

这个方案可以实现复杂环绕、俯冲、拉升等动作,但是如果不是动态模型运动没太大必要用这个。

完整代码

// 环绕展示工厂模型
const displayFactoryModel = () => {
    if (!cesiumViewer.value) return;
    
    const center = Cesium.Cartesian3.fromDegrees(117.104273, 36.437867, 0);
    const radius = 150;
    const height = 80;
    const points = 12; // 路径点数
    const duration = 30; // 总飞行时间
    
    // 创建路径点
    const positions = [];
    for (let i = 0; i <= points; i++) {
        const angle = (i / points) * 2 * Math.PI;
        const x = center.x + radius * Math.cos(angle);
        const y = center.y + radius * Math.sin(angle);
        const z = center.z + height;
        positions.push(new Cesium.Cartesian3(x, y, z));
    }
    
    // 相机沿路径飞行
    cesiumViewer.value.camera.flyTo({
        destination: positions,
        orientation: {
            heading: Cesium.Math.toRadians(0),
            pitch: Cesium.Math.toRadians(-30),
            roll: 0.0
        },
        duration: duration,
        complete: () => {
            console.log('✅ 环绕展示完成');
        }
    });
};

camera.rotate 旋转(极简)

这种方案代码量最少,一行核心逻辑。直接使用原地旋转视角,不改变相机位置。

但是展示效果一般,只能原地自转,不能环绕。

完整代码

// 环绕展示工厂模型
const displayFactoryModel = () => {
    if (!cesiumViewer.value) return;
    
    // 先飞到模型上方
    cesiumViewRef.value.flyToLocation({
        longitude: 117.104273,
        latitude: 36.437867,
        height: 80,
        duration: 3,
        heading: 0,
        pitch: -30,
        onComplete: () => {
            // 开始旋转
            let angle = 0;
            const rotateInterval = setInterval(() => {
                angle = (angle + 1) % 360;
                cesiumViewer.value.camera.setView({
                    orientation: {
                        heading: Cesium.Math.toRadians(angle),
                        pitch: Cesium.Math.toRadians(-30),
                        roll: 0.0
                    }
                });
            }, 50); // 每50ms旋转1度
            
            // 10秒后停止
            setTimeout(() => {
                clearInterval(rotateInterval);
                console.log('✅ 旋转结束');
            }, 10000);
        }
    });
};

总结

模型环绕展示是Cesium展示模型非常好的一种方案,尤其是做会议室大屏使用。

展示修效果还是相当不错的,如果没有特殊要求,可以考虑使用 lookAt+帧动画,这是综合体验最优的方案。

强烈建议大家使用此方案,流畅不卡顿,用户体验直接拉满!

React-路由监听 / 跳转 / 守卫全攻略(附实战代码)

2026年3月23日 20:44

前言

React Router 是 React 单页应用的核心路由库,除了基础的路由配置,日常开发中还会高频用到路由监听、编程式跳转、路由守卫等进阶功能。本文从实战角度拆解这三大核心能力,涵盖实现方式、场景对比、避坑要点,基于 React Router v6+ 版本(主流稳定版)讲解,新手也能快速落地!

一、 路由监听:如何捕捉 URL 的变化?

在 React 中,React 监听路由变化的本质是监听 URL 相关属性(pathname/search/params 等)的变化,触发自定义回调函数。以下是 3 种常用实现方式:

1. 核心方案:useLocation + useEffect

这是最通用的监听方式。通过 useLocation 获取当前路由完整信息(pathname/search/state 等),结合 useEffect 监听 location 对象变化,触发回调函数。

import { useLocation } from 'react-router-dom';
import { useEffect } from 'react';

const App = () => {
  const location = useLocation();

  useEffect(() => {
    // 每次路由切换时执行
    console.log('当前路径:', location.pathname);
    console.log('搜索参数:', location.search);
  }, [location]); 
};

2. 精准监听:useParamsuseSearchParams

如果你只关心某个特定的动态参数(如 id),直接监听参数对象会更高效。

  • useParams:监听动态路由参数(如 /user/:id 中的 id);

  • useSearchParams:监听 URL 搜索参数(如 ?page=1&size=10);

  • 适用场景:

    • 仅关注动态路由参数或搜索参数变化的场景(如详情页 ID 切换、列表页分页参数变化)。

3.监听原生路由事件(不推荐)

  • 通过 window.addEventListener 监听 popstate(History 模式)或 hashchange(Hash 模式)事件,直接捕获 URL 变化。

  • 缺点:React Router 已封装原生事件,手动监听易出现重复触发、状态不一致问题,仅建议特殊场景(如兼容老代码)使用。


二、 路由跳转

React Router 提供多种跳转方式,适配「点击跳转」「编程式跳转」「导航栏高亮」等不同场景:

1. 声明式导航:<Link><NavLink>(最常用)

  • <Link> :基础跳转,React Router 核心跳转组件,替代原生 <a> 标签(避免页面刷新),核心属性如下:

    • to:必传,目标路径(支持字符串 / 对象格式);

    • replace:默认 false(新增历史记录),true 则替换当前记录(跳转后无法回退);

    • state:传递自定义状态(不显示在 URL 中,通过 useLocation().state 获取)。

  • <NavLink> :专为导航栏设计,新增激活状态相关配置,适合导航栏场景:

    • isActive:可根据激活状态动态设置样式类名。

    • end:精准匹配模式,防止 / 匹配到所有子路由(如 /home 不匹配 /home/detail)。

2. 编程式导航:useNavigate

适用于点击按钮后的逻辑处理或异步请求后的跳转,通过 useNavigate Hook 获取导航函数,实现非点击触发的跳转(如接口请求后、条件判断后)。

const navigate = useNavigate();

// 基础跳转,带状态
navigate('/profile', { replace: true, state: { from: 'home' } });

// 历史记录操作
navigate(-1); // 后退一步

三、 路由守卫:在 React 中如何“拦截”?

Vue 有原生的 beforeEach/afterEach 路由守卫,但 React Router 无专属 API,核心通过监听路由 + 条件判断实现,分为 3 类场景:

1. 全局路由守卫

实现逻辑

在路由根组件(如 App.jsx)中监听 location 变化,执行全局校验(如登录状态、白名单),拦截非法跳转。

实战代码

import { useLocation, useNavigate, useEffect } from 'react-router-dom';
import { isLogin } from '@/utils/auth'; // 自定义登录校验函数

// 全局路由守卫组件
const GlobalRouterGuard = () => {
  const location = useLocation();
  const navigate = useNavigate();
  // 无需登录的白名单路由
  const whiteList = ['/login', '/register'];

  useEffect(() => {
    // 未登录且不在白名单 → 跳转到登录页
    if (!isLogin() && !whiteList.includes(location.pathname)) {
      navigate('/login', { 
        replace: true,
        state: { from: location.pathname } // 记录来源路径,登录后跳转回去
      });
    }
  }, [location.pathname, navigate]);

  return null; // 守卫组件无需渲染 DOM
};

// 在根路由中引入
// <BrowserRouter>
//   <GlobalRouterGuard />
//   <Routes>...</Routes>
// </BrowserRouter>

2. 组件内路由守卫

实现逻辑

在组件内通过 useEffect 实现进入守卫(组件挂载时校验),通过 useEffect 的返回函数实现离开守卫(组件卸载时执行)。

实战代码

import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { hasPermission } from '@/utils/permission'; // 自定义权限校验

const UserDetail = () => {
  const navigate = useNavigate();

  // 进入守卫:组件挂载时校验权限
  useEffect(() => {
    if (!hasPermission('user:view')) {
      navigate('/403', { replace: true });
    }
  }, [navigate]);

  // 离开守卫:组件卸载时执行(如保存表单、提示未提交内容)
  useEffect(() => {
    return () => {
      console.log('离开用户详情页,执行清理逻辑');
      // 业务逻辑:保存草稿、关闭WebSocket等
    };
  }, []);

  return <div>用户详情页</div>;
};

3. 路由独享守卫

  • 概念: 不影响全局其他路由,只针对这一个页面(或这一组页面)进行特殊的准入检查。 例如整个应用都可以访问,但只有/admin页面需要检查管理员权限

  • 实现”先创建一个守卫组件AdminGuard,这个组件专门负责检查当前用户是不是管理员,然后将需要单独检查的Admin路由套在这个守卫组件AdminGuard里面

实战代码:

// 守卫组件:AdminGuard.jsx
import { useNavigate } from 'react-router-dom';
import { isAdmin } from '@/utils/auth';

const AdminGuard = ({ children }) => {
  const navigate = useNavigate();
  
  // 管理员权限校验
  if (!isAdmin()) {
    navigate('/403', { replace: true });
    return null;
  }

  // 权限通过,渲染子组件
  return children;
};

// 路由配置中使用
import { Routes, Route } from 'react-router-dom';
import SettingsPage from '@/pages/Settings';

const RouterConfig = () => {
  return (
    <Routes>
      {/* 独享守卫:仅 /settings 路由触发管理员校验 */}
      <Route 
        path="/settings" 
        element={
          <AdminGuard>
            <SettingsPage />
          </AdminGuard>
        } 
      />
    </Routes>
  );
};

4.扩展:离开时的路由拦截(useBlocker)

useBlocker 是 React Router v6 新增 Hook,用于拦截所有路由跳转行为(包括 <Link>navigate、浏览器前进 / 后退)。

import { useBlocker } from 'react-router-dom';

// blockerFn:返回 true 拦截跳转,false 放行
// when:是否启用拦截(可选,默认 true)
useBlocker((tx) => {
  console.log('即将跳转到:', tx.location.pathname);
  return true; // 拦截跳转
}, when);

实战场景:表单未提交拦截

import { useBlocker } from 'react-router-dom';

const FormPage = () => {
  const [formDirty, setFormDirty] = useState(false); // 表单是否修改

  // 表单未提交时拦截跳转
  useBlocker((tx) => {
    if (formDirty) {
      const confirm = window.confirm('表单内容未保存,是否确认离开?');
      return !confirm; // 点击取消 → 拦截(返回 true)
    }
    return false; // 放行
  }, formDirty); // 仅表单修改时启用拦截

  return (
    <form onChange={() => setFormDirty(true)}>
      <input type="text" placeholder="输入内容..." />
    </form>
  );
};

四、 总结与最佳实践

  1. 优先使用原生 Link:对于简单的跳转,<Link> 的性能和 SEO 优于 useNavigate
  2. 善用 State 传参:如果不想 URL 变得太长,利用 location.state 传递对象是最佳选择。
  3. 守卫逻辑模块化:不要在 App.js 里写一堆 if-else,将权限校验封装成独立的 Guard 组件。
昨天 — 2026年3月23日掘金 前端

Tauri v2 实战代码示例

2026年3月23日 18:24

执行摘要

Tauri v2 是一个基于 Rust 构建的跨平台桌面应用框架,它将 Web 前端技术与 Rust 后端相结合,提供了体积小、速度快、安全性高的应用开发解决方案。本文档收集并整理了 Tauri v2 的完整实战代码示例,涵盖项目初始化配置、Rust 命令定义、前端交互、事件系统、插件开发、窗口管理、文件系统操作以及 HTTP 请求处理等核心使用场景。所有示例均来源于官方文档、GitHub 仓库及经过验证的开发者教程,确保代码的正确性和可运行性。


一、项目初始化与配置

1.1 项目创建

Tauri v2 项目的创建可以通过多种包管理器完成。创建过程中会提示选择前端框架(如 Vue、React、Svelte 或纯 HTML)以及 TypeScript 支持选项。创建完成后,项目会包含前端源码目录和 src-tauri Rust 后端目录两个主要部分[1]。

使用 pnpm 创建项目的命令如下:

pnpm create tauri-app

其他包管理器的创建命令包括 npm create tauri-app@latestyarn create tauri-appcargo create-tauri-app 等[2]。

1.2 tauri.conf.json 基础配置

tauri.conf.json 是 Tauri 应用程序的核心配置文件,位于 src-tauri 目录中。以下是一个完整的基础配置示例:

{
  "$schema": "../node_modules/@tauri-apps/cli/schema.json",
  "build": {
    "beforeDevCommand": "pnpm dev",
    "beforeBuildCommand": "pnpm build",
    "devPath": "http://localhost:1420",
    "distDir": "../dist",
    "devtools": true
  },
  "package": {
    "productName": "My Tauri App",
    "version": "1.0.0"
  },
  "tauri": {
    "windows": [
      {
        "title": "My Tauri Application",
        "width": 800,
        "height": 600,
        "resizable": true,
        "fullscreen": false,
        "decorations": true,
        "center": true
      }
    ],
    "security": {
      "csp": null
    }
  },
  "bundle": {
    "active": true,
    "targets": "all",
    "identifier": "com.myapp.example",
    "icon": [
      "icons/32x32.png",
      "icons/128x128.png",
      "icons/128x128@2x.png",
      "icons/icon.icns",
      "icons/icon.ico"
    ]
  }
}

该配置文件定义了构建命令、开发服务器路径、窗口属性以及打包设置。其中 devtools 选项在开发阶段应设置为 true 以便于调试[3]。

1.3 Cargo.toml 依赖配置

Rust 后端的依赖管理通过 src-tauri/Cargo.toml 文件完成。以下是集成常用插件的依赖配置:

[package]
name = "my-tauri-app"
version = "1.0.0"
description = "A Tauri Application"
authors = ["Developer"]
edition = "2021"

[build-dependencies]
tauri-build = { version = "2", features = [] }

[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-fs = "2"
tauri-plugin-http = "2"
tauri-plugin-shell = "2"
tauri-plugin-os = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

Tauri v2 采用插件化架构,文件系统、HTTP 请求、Shell 命令等功能都需要通过相应的插件来实现[4]。

1.4 平台特定配置

Tauri 支持平台特定的配置文件,包括 tauri.linux.conf.jsontauri.windows.conf.jsontauri.macos.conf.json。这些文件会与主配置文件合并,允许开发者为不同平台定制配置[3]。

1.5 权限与能力系统

Tauri v2 引入了基于能力的权限管理系统。权限配置文件位于 src-tauri/capabilities/ 目录中,每个窗口可以拥有独立的能力配置。以下是一个完整的能力配置示例:

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "main-capability",
  "description": "Main window capabilities",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "core:window:default",
    "core:window:allow-minimize",
    "core:window:allow-maximize",
    "core:window:allow-close",
    "core:window:allow-set-title",
    "core:window:allow-start-dragging",
    "fs:default",
    "fs:allow-read-text-file",
    "fs:allow-write-text-file",
    "http:default",
    {
      "identifier": "http:allow-fetch",
      "allow": [
        { "url": "https://*.api.example.com" },
        { "url": "https://jsonplaceholder.typicode.com" }
      ]
    },
    "shell:allow-open",
    "os:default"
  ]
}

权限系统支持精细的作用域控制,可以限制对特定路径或域名的访问。变量如 $HOME$APPDATA$APPCACHE 等可用于路径配置[5]。


二、Rust 后端命令定义

2.1 基本命令定义

Tauri 的命令系统允许前端 JavaScript 调用 Rust 函数。命令通过 #[tauri::command] 属性宏定义,并使用 #[tauri::command] 注解的函数必须放在 src-tauri/src/lib.rs 文件中(非 main.rs)[6]。

以下是最基本的命令定义示例:

#[tauri::command]
fn my_custom_command() {
    println!("I was invoked from JavaScript!");
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![my_custom_command])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

2.2 命令模块化

当命令较多时,建议将命令分离到单独的文件或模块中:

// src-tauri/src/commands.rs
#[tauri::command]
pub fn command_a() -> String {
    "Command A result".into()
}

#[tauri::command]
pub fn command_b(value: String) -> Result<String, String> {
    if value.is_empty() {
        Err("Value cannot be empty".into())
    } else {
        Ok(format!("Received: {}", value))
    }
}
// src-tauri/src/lib.rs
mod commands;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            commands::command_a,
            commands::command_b
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

2.3 带参数的命令

命令可以接受参数,参数类型支持 Rust 的基本类型和实现了 serde::Deserialize 的自定义类型:

#[tauri::command]
fn greet(name: String, age: u32) -> String {
    format!("Hello, {}! You are {} years old.", name, age)
}

// 使用 snake_case 重命名规则
#[tauri::command(rename_all = "snake_case")]
fn process_data(my_value: String, another_field: bool) -> String {
    format!("Value: {}, Field: {}", my_value, another_field)
}

2.4 返回数据与错误处理

命令可以返回结果,也可以返回错误。以下是完整的错误处理模式:

use serde::Serialize;

// 基本错误处理
#[tauri::command]
fn login(user: String, password: String) -> Result<String, String> {
    if user == "admin" && password == "admin123" {
        Ok("login_success".to_string())
    } else {
        Err("Invalid credentials".to_string())
    }
}

// 使用 thiserror 库定义自定义错误类型
#[derive(Debug, thiserror::Error)]
enum AppError {
    #[error(transparent)]
    Io(#[from] std::io::Error),
    #[error("Parse error: {0}")]
    Parse(String),
}

impl Serialize for AppError {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::ser::Serializer,
    {
        serializer.serialize_str(&self.to_string())
    }
}

#[tauri::command]
fn read_file_content(path: String) -> Result<String, AppError> {
    std::fs::read_to_string(&path).map_err(AppError::from)
}

2.5 异步命令

异步命令使用 async fn 语法,需要注意借用规则:

use tokio::time::{sleep, Duration};

// 方式一:使用 String 替代 &str 避免借用问题
#[tauri::command]
async fn async_operation(value: String) -> String {
    sleep(Duration::from_secs(1)).await;
    format!("Processed: {}", value)
}

// 方式二:返回 Result
#[tauri::command]
async fn async_with_result(url: String) -> Result<String, String> {
    // 模拟异步操作
    sleep(Duration::from_millis(100)).await;
    Ok(format!("Fetched from: {}", url))
}

2.6 访问状态管理

Tauri 提供状态管理功能,可以在应用生命周期内共享数据:

use std::sync::Mutex;

struct AppState {
    counter: Mutex<i32>,
    config: Mutex<AppConfig>,
}

#[derive(Debug, Clone, serde::Serialize)]
struct AppConfig {
    theme: String,
    language: String,
}

#[tauri::command]
fn increment_counter(state: tauri::State<'_, AppState>) -> i32 {
    let mut counter = state.counter.lock().unwrap();
    *counter += 1;
    *counter
}

#[tauri::command]
fn get_config(state: tauri::State<'_, AppState>) -> AppConfig {
    state.config.lock().unwrap().clone()
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    let app_state = AppState {
        counter: Mutex::new(0),
        config: Mutex::new(AppConfig {
            theme: "dark".to_string(),
            language: "en".to_string(),
        }),
    };

    tauri::Builder::default()
        .manage(app_state)
        .invoke_handler(tauri::generate_handler![
            increment_counter,
            get_config
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

2.7 访问 WebviewWindow 和 AppHandle

命令可以接收 WebviewWindowAppHandle 参数以执行窗口操作或访问应用级功能:

#[tauri::command]
async fn open_settings_window(app_handle: tauri::AppHandle) -> Result<(), String> {
    use tauri::{WebviewUrl, WebviewWindowBuilder};

    WebviewWindowBuilder::new(
        &app_handle,
        "settings",
        WebviewUrl::App("settings.html".into()),
    )
    .title("Settings")
    .inner_size(600.0, 400.0)
    .build()
    .map_err(|e| e.to_string())?;

    Ok(())
}

#[tauri::command]
fn get_window_label(window: tauri::Window) -> String {
    window.label().to_string()
}

#[tauri::command]
async fn send_notification(app_handle: tauri::AppHandle, title: String, body: String) {
    use tauri::Manager;
    if let Some(window) = app_handle.get_webview_window("main") {
        let _ = window.emit("notification", serde_json::json!({
            "title": title,
            "body": body
        }));
    }
}

2.8 使用 Channel 进行流式数据传输

Channel 允许从 Rust 端向前端发送流式数据:

use tauri::{AppHandle, ipc::Channel};
use serde::Serialize;

#[derive(Clone, Serialize)]
#[serde(tag = "event", content = "data")]
enum DownloadEvent {
    Started { url: String, size: u64 },
    Progress { bytes_downloaded: u64 },
    Finished { success: bool },
}

#[tauri::command]
async fn download_file(
    app: AppHandle,
    url: String,
    on_progress: Channel<DownloadEvent>,
) -> Result<(), String> {
    let total_size: u64 = 1000;

    on_progress
        .send(DownloadEvent::Started {
            url: url.clone(),
            size: total_size,
        })
        .map_err(|e| e.to_string())?;

    for i in (0..total_size).step_by(100) {
        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
        on_progress
            .send(DownloadEvent::Progress {
                bytes_downloaded: i,
            })
            .map_err(|e| e.to_string())?;
    }

    on_progress
        .send(DownloadEvent::Finished { success: true })
        .map_err(|e| e.to_string())?;

    Ok(())
}

三、前端调用 Rust 代码

3.1 使用 npm 包调用命令

Tauri 2.x 推荐使用 @tauri-apps/api 包中的 invoke 函数来调用 Rust 命令:

npm install @tauri-apps/api

基础调用方式如下:

import { invoke } from '@tauri-apps/api/core';

// 无参数命令
async function callBasicCommand() {
  await invoke('my_custom_command');
}

// 带参数命令
async function callWithArgs() {
  const result = await invoke('greet', { name: 'Alice', age: 30 });
  console.log(result);
}

// 返回复杂对象
async function getData() {
  const data = await invoke('get_config');
  console.log(data.theme, data.language);
}

3.2 TypeScript 类型支持

可以为命令定义 TypeScript 类型以获得完整的类型提示:

interface CustomResponse {
  message: string;
  otherVal: number;
}

interface ErrorKind {
  kind: 'io' | 'utf8';
  message: string;
}

async function fetchData(): Promise<CustomResponse> {
  try {
    const response = await invoke<CustomResponse>('my_custom_command', {
      number: 42,
    });
    return response;
  } catch (error) {
    const typedError = error as ErrorKind;
    console.error(`Error (${typedError.kind}): ${typedError.message}`);
    throw error;
  }
}

3.3 错误处理

前端可以通过 Promise 的 catch 方法处理命令返回的错误:

async function login(username, password) {
  try {
    const result = await invoke('login', {
      user: username,
      password: password
    });
    console.log('Login result:', result);
    return result;
  } catch (error) {
    console.error('Login failed:', error);
    return null;
  }
}

3.4 调用异步命令

异步命令的调用方式与普通命令相同,invoke 会自动等待异步操作完成:

async function performAsyncOperation() {
  try {
    const result = await invoke('async_operation', {
      value: 'test data'
    });
    console.log('Async result:', result);
  } catch (error) {
    console.error('Async operation failed:', error);
  }
}

3.5 使用 Global Tauri(可选)

如果不想使用 npm 包,可以在 tauri.conf.json 中启用全局 Tauri:

{
  "tauri": {
    "app": {
      "withGlobalTauri": true
    }
  }
}

然后使用全局对象调用命令:

// 使用全局脚本
const invoke = window.__TAURI__.invoke;
await invoke('my_custom_command');

3.6 原始请求调用

对于需要发送原始字节数据的场景,Tauri 2 支持原始请求调用:

const data = new Uint8Array([1, 2, 3, 4, 5]);
await invoke('upload', data, {
  headers: {
    'Authorization': 'Bearer token123',
    'Content-Type': 'application/octet-stream'
  }
});

四、事件系统

4.1 从 Rust 发送全局事件

Tauri 的事件系统允许 Rust 后端向所有前端监听器广播消息:

use tauri::{AppHandle, Emitter};

#[tauri::command]
fn download(app: AppHandle, url: String) {
    // 发送初始事件
    app.emit("download-started", &url).unwrap();

    // 发送进度事件
    for progress in [10, 30, 50, 70, 90, 100] {
        app.emit("download-progress", progress).unwrap();
        std::thread::sleep(std::time::Duration::from_millis(200));
    }

    // 发送完成事件
    app.emit("download-finished", &url).unwrap();
}

4.2 发送事件到特定 Webview

使用 emit_to 方法可以将事件发送到特定窗口:

use tauri::{AppHandle, Emitter};

#[tauri::command]
fn login(app: AppHandle, user: String, password: String) {
    let result = if user == "admin" && password == "admin" {
        "success"
    } else {
        "failed"
    };

    // 只向 login 窗口发送结果
    app.emit_to("login", "login-result", result).unwrap();
}

4.3 使用事件过滤器

emit_filter 允许根据条件选择目标窗口:

use tauri::{AppHandle, Emitter, EventTarget};

#[tauri::command]
fn broadcast_to_main(app: AppHandle, message: String) {
    app.emit_filter("broadcast", message, |target| {
        match target {
            EventTarget::WebviewWindow { label } => {
                label == "main" || label == "dashboard"
            },
            _ => false,
        }
    }).unwrap();
}

4.4 前端监听全局事件

JavaScript 端使用 listen 函数监听事件:

import { listen } from '@tauri-apps/api/event';

interface DownloadStarted {
  url: string;
  downloadId: number;
  contentLength: number;
}

// 监听下载开始事件
const unlisten = await listen<DownloadStarted>('download-started', (event) => {
  console.log(`开始下载: ${event.payload.url}`);
  console.log(`文件大小: ${event.payload.contentLength} bytes`);
});

// 监听进度更新
await listen<number>('download-progress', (event) => {
  updateProgressBar(event.payload);
});

// 监听下载完成
await listen<string>('download-finished', (event) => {
  console.log(`下载完成: ${event.payload}`);
  showNotification('Download complete!');
});

// 停止监听
unlisten();

4.5 前端监听特定窗口事件

获取特定窗口实例并监听其事件:

import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';

const appWindow = getCurrentWebviewWindow();

// 监听来自该窗口的事件
appWindow.listen<string>('login-result', (event) => {
  if (event.payload === 'success') {
    navigateToDashboard();
  } else {
    showError('Login failed');
  }
});

4.6 监听一次事件

使用 once 函数监听仅触发一次的事件:

import { once } from '@tauri-apps/api/event';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';

// 全局事件监听一次
once('app-ready', (event) => {
  console.log('App is ready!');
});

// 特定窗口事件监听一次
const appWindow = getCurrentWebviewWindow();
appWindow.once('initial-data-loaded', () => {
  renderApp();
});

4.7 从 Rust 端监听事件

在 Rust 端也可以监听前端发送的事件:

use tauri::{Listener, Manager};

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            // 监听全局事件
            app.listen("my-event", |event| {
                println!("Received global event: {:?}", event.payload());
            });

            // 监听特定窗口事件
            if let Some(window) = app.get_webview_window("main") {
                window.listen("frontend-event", |event| {
                    println!("Frontend event: {:?}", event.payload());
                });
            }

            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

4.8 停止监听

可以通过 unlisten 函数停止事件监听:

import { listen } from '@tauri-apps/api/event';

const unlisten = await listen('status-update', (event) => {
  console.log('Status:', event.payload);
});

// 稍后停止监听
setTimeout(() => {
  unlisten();
}, 5000);

五、插件开发

5.1 插件项目结构

Tauri 插件拥有独立的目录结构,包含 Rust 实现和 JavaScript API:

tauri-plugin-[name]/
├── src/
│   ├── lib.rs           # 插件入口和初始化
│   ├── commands.rs      # 命令定义
│   ├── desktop.rs       # 桌面平台实现
│   ├── mobile.rs        # 移动平台实现
│   ├── error.rs         # 错误类型定义
│   └── models.rs        # 共享数据结构
├── permissions/         # 权限定义文件
├── guest-js/           # JavaScript API 源码
├── dist-js/            # 编译后的 JavaScript
├── android/            # Android 原生代码
├── ios/                # iOS 原生代码
├── Cargo.toml
└── package.json

5.2 插件初始化

插件通过 Builder 模式初始化:

// src/lib.rs
use tauri::plugin::{Builder, Runtime, TauriPlugin};
use serde::Deserialize;

#[derive(Deserialize)]
struct PluginConfig {
    timeout: usize,
}

pub fn init<R: Runtime>() -> TauriPlugin<R, PluginConfig> {
    Builder::<R, PluginConfig>::new("my-plugin")
        .setup(|app, api| {
            let timeout = api.config().timeout;
            println!("Plugin initialized with timeout: {}", timeout);
            Ok(())
        })
        .build()
}

5.3 生命周期钩子

插件支持多种生命周期钩子:

use tauri::{Manager, plugin::Builder, RunEvent};

Builder::new("my-plugin")
    // 插件初始化
    .setup(|app, _api| {
        app.manage(MyState::default());
        Ok(())
    })
    // WebView 导航拦截
    .on_navigation(|window, url| {
        println!("Window {} navigating to {}", window.label(), url);
        url.scheme() != "blocked"  // 返回 false 阻止导航
    })
    // WebView 创建完成
    .on_webview_ready(|window| {
        window.listen("content-loaded", |_| {
            println!("Content loaded in window");
        });
    })
    // 事件循环事件
    .on_event(|app, event| {
        match event {
            RunEvent::ExitRequested { api, .. } => {
                // 阻止退出
                api.prevent_exit();
            }
            RunEvent::Exit => {
                // 清理资源
                println!("App is exiting");
            }
            _ => {}
        }
    })
    // 插件销毁
    .on_drop(|app| {
        println!("Plugin destroyed");
    })

5.4 定义命令

在插件中定义可从调用的命令:

// src/commands.rs
use tauri::{command, AppHandle, Runtime, Window, ipc::Channel};

#[command]
pub async fn start_server<R: Runtime>(
    app: AppHandle<R>,
    port: u16,
    on_event: Channel,
) -> Result<(), String> {
    // 实现服务器启动逻辑
    on_event.send("server-started").map_err(|e| e.to_string())?;

    // 发送定期心跳
    for i in 0..10 {
        tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
        on_event.send(format!("heartbeat-{}", i)).map_err(|e| e.to_string())?;
    }

    Ok(())
}

5.5 注册命令和 JavaScript API

// src/lib.rs
pub fn init<R: Runtime>() -> TauriPlugin<R> {
    Builder::<R>::new("my-plugin")
        .invoke_handler(tauri::generate_handler![
            commands::start_server
        ])
        .build()
}
// guest-js/index.ts
import { invoke, Channel } from '@tauri-apps/api/core';

export async function startServer(
  port: number,
  onEventHandler: (event: string) => void
): Promise<void> {
  const onEvent = new Channel<string>();
  onEvent.onmessage = onEventHandler;
  await invoke('plugin:my-plugin|start_server', { port, onEvent });
}

5.6 权限定义

插件通过 TOML 文件定义权限:

# permissions/start-server.toml
"$schema" = "../gen/schemas/schema.json"

[[permission]]
identifier = "allow-start-server"
description = "Enables the start_server command"
commands.allow = ["start_server"]

[[permission]]
identifier = "deny-start-server"
description = "Denies the start_server command"
commands.deny = ["start_server"]

作用域权限定义:

# permissions/spawn-process.toml
[[permission]]
identifier = "allow-spawn"
description = "Allows spawning processes"

[[permission.scope.allow]]
binary = "node"
args = ["--version"]

[[permission.scope.deny]]
binary = "rm"

5.7 在应用中使用插件

// src-tauri/src/lib.rs
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(my_plugin::init())
        .plugin(tauri_plugin_fs::init())
        .plugin(tauri_plugin_http::init())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

5.8 自动生成权限

通过构建脚本自动生成权限:

// build.rs
const COMMANDS: &[&str] = &["upload", "download"];

fn main() {
    tauri_plugin::Builder::new(COMMANDS).build();
}

这会自动生成 allow-uploaddeny-uploadallow-downloaddeny-download 等权限。


六、窗口管理

6.1 窗口配置

通过 tauri.conf.json 配置窗口属性:

{
  "tauri": {
    "windows": [
      {
        "label": "main",
        "title": "My Application",
        "width": 1024,
        "height": 768,
        "minWidth": 640,
        "minHeight": 480,
        "resizable": true,
        "fullscreen": false,
        "decorations": true,
        "transparent": false,
        "center": true,
        "x": 100,
        "y": 100,
        "visible": true,
        "focus": true
      }
    ]
  }
}

6.2 运行时创建窗口

使用 WebviewWindowBuilder 在运行时创建新窗口:

use tauri::{WebviewUrl, WebviewWindowBuilder, Manager};

#[tauri::command]
async fn open_settings(app: tauri::AppHandle) -> Result<(), String> {
    WebviewWindowBuilder::new(
        &app,
        "settings",                      // 唯一标签
        WebviewUrl::App("settings.html".into()),
    )
    .title("Settings")
    .inner_size(600.0, 400.0)
    .resizable(true)
    .center()
    .build()
    .map_err(|e| e.to_string())?;

    Ok(())
}

6.3 自定义标题栏

实现自定义标题栏需要禁用原生窗口装饰:

{
  "tauri": {
    "windows": [
      {
        "decorations": false,
        "transparent": true
      }
    ]
  }
}

HTML 结构:

<div class="titlebar" data-tauri-drag-region>
  <div class="title">My App</div>
  <div class="controls">
    <button id="btn-minimize">
      <svg><!-- 最小化图标 --></svg>
    </button>
    <button id="btn-maximize">
      <svg><!-- 最大化图标 --></svg>
    </button>
    <button id="btn-close">
      <svg><!-- 关闭图标 --></svg>
    </button>
  </div>
</div>

CSS 样式:

.titlebar {
  height: 32px;
  background: #2d3748;
  display: flex;
  justify-content: space-between;
  align-items: center;
  user-select: none;
}

.titlebar button {
  width: 46px;
  height: 32px;
  border: none;
  background: transparent;
  color: white;
  cursor: pointer;
}

.titlebar button:hover {
  background: rgba(255, 255, 255, 0.1);
}

[data-tauri-drag-region] {
  flex: 1;
  height: 100%;
  cursor: move;
}

JavaScript 控制逻辑:

import { getCurrentWindow } from '@tauri-apps/api/window';

const appWindow = getCurrentWindow();

document.getElementById('btn-minimize')?.addEventListener('click', () => {
  appWindow.minimize();
});

document.getElementById('btn-maximize')?.addEventListener('click', async () => {
  const isMaximized = await appWindow.isMaximized();
  if (isMaximized) {
    appWindow.unmaximize();
  } else {
    appWindow.maximize();
  }
});

document.getElementById('btn-close')?.addEventListener('click', () => {
  appWindow.close();
});

6.4 窗口操作

完整的窗口操作 API:

import { getCurrentWindow } from '@tauri-apps/api/window';

const appWindow = getCurrentWindow();

// 最小化
await appWindow.minimize();

// 最大化
await appWindow.maximize();

// 恢复窗口
await appWindow.unmaximize();

// 关闭窗口
await appWindow.close();

// 设置窗口标题
await appWindow.setTitle('New Title');

// 获取窗口是否最大化
const isMax = await appWindow.isMaximized();

// 获取窗口是否最小化
const isMin = await appWindow.isMinimized();

// 设置焦点
await appWindow.setFocus();

// 设置全屏
await appWindow.setFullscreen(true);

// 检查是否全屏
const isFullscreen = await appWindow.isFullscreen();

// 设置窗口始终在最前
await appWindow.setAlwaysOnTop(true);

// 开始拖动窗口
await appWindow.startDragging();

6.5 多窗口管理

创建和管理多个窗口:

use tauri::{AppHandle, Manager, Emitter};

#[tauri::command]
async fn open_window(app: AppHandle, label: String, url: String) -> Result<(), String> {
    if app.get_webview_window(&label).is_some() {
        // 窗口已存在,聚焦
        if let Some(window) = app.get_webview_window(&label) {
            window.set_focus().map_err(|e| e.to_string())?;
        }
        return Ok(());
    }

    // 创建新窗口
    tauri::WebviewWindowBuilder::new(&app, &label, url.parse().unwrap())
        .title(&label)
        .inner_size(800.0, 600.0)
        .center()
        .build()
        .map_err(|e| e.to_string())?;

    Ok(())
}

#[tauri::command]
async fn close_window(app: AppHandle, label: String) -> Result<(), String> {
    if let Some(window) = app.get_webview_window(&label) {
        window.close().map_err(|e| e.to_string())?;
    }
    Ok(())
}

#[tauri::command]
async fn list_windows(app: AppHandle) -> Vec<String> {
    app.webview_windows()
        .keys()
        .cloned()
        .collect()
}

6.6 窗口事件监听

监听窗口状态变化:

import { getCurrentWindow } from '@tauri-apps/api/window';

const appWindow = getCurrentWindow();

// 监听窗口大小变化
appWindow.onResized(async ({ payload }) => {
  const size = await appWindow.innerSize();
  console.log(`Window resized to ${size.width}x${size.height}`);
});

// 监听窗口移动
appWindow.onMoved(async ({ payload }) => {
  const position = await appWindow.outerPosition();
  console.log(`Window moved to ${position.x}, ${position.y}`);
});

// 监听窗口关闭请求
appWindow.onCloseRequested(async (event) => {
  const shouldClose = confirm('Are you sure you want to close?');
  if (!shouldClose) {
    event.preventClose();
  }
});

// 监听窗口焦点变化
appWindow.onFocusChanged(({ payload: focused }) => {
  console.log(`Window focus: ${focused}`);
});

6.7 macOS 透明标题栏

针对 macOS 平台的特殊配置:

use tauri::{TitleBarStyle, WebviewUrl, WebviewWindowBuilder};

pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            let win = WebviewWindowBuilder::new(
                app,
                "main",
                WebviewUrl::default(),
            )
            .title("My App")
            .inner_size(800.0, 600.0);

            #[cfg(target_os = "macos")]
            let win = win.title_bar_style(TitleBarStyle::Transparent);

            win.build().unwrap();
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

七、文件系统操作

7.1 插件安装与初始化

Tauri 的文件系统功能通过 tauri-plugin-fs 提供:

cargo add tauri-plugin-fs

在 Rust 端初始化:

// src-tauri/src/lib.rs
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

7.2 权限配置

在 capabilities 中配置文件系统权限:

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "fs-capability",
  "windows": ["main"],
  "permissions": [
    "fs:default",
    {
      "identifier": "fs:allow-read-text-file",
      "allow": [
        { "path": "$APPDATA/*" },
        { "path": "$HOME/test.txt" }
      ]
    },
    {
      "identifier": "fs:allow-write-text-file",
      "allow": [
        { "path": "$APPDATA" },
        { "path": "$APPDATA/**" }
      ]
    }
  ]
}

7.3 读取文本文件

import { readTextFile, BaseDirectory } from '@tauri-apps/plugin-fs';

// 使用基础目录读取
async function loadConfig() {
  const content = await readTextFile('config.json', {
    baseDir: BaseDirectory.AppConfig,
  });
  return JSON.parse(content);
}

// 使用路径 API 组合完整路径
import { homeDir, join } from '@tauri-apps/plugin-path';

async function readUserFile() {
  const home = await homeDir();
  const filePath = await join(home, 'documents', 'notes.txt');
  const content = await readTextFile(filePath);
  return content;
}

7.4 写入文本文件

import { writeTextFile, BaseDirectory, mkdir, exists } from '@tauri-apps/plugin-fs';

async function saveData(data) {
  const dir = 'myapp';
  const file = 'data.json';

  // 确保目录存在
  if (!(await exists(dir, { baseDir: BaseDirectory.AppData }))) {
    await mkdir(dir, { baseDir: BaseDirectory.AppData, recursive: true });
  }

  // 写入文件
  await writeTextFile(
    `${dir}/${file}`,
    JSON.stringify(data, null, 2),
    { baseDir: BaseDirectory.AppData }
  );
}

7.5 二进制文件操作

import { readFile, writeFile, BaseDirectory } from '@tauri-apps/plugin-fs';

// 读取二进制文件(如图片)
async function loadImage() {
  const bytes = await readFile('icon.png', {
    baseDir: BaseDirectory.Resource,
  });
  return bytes;
}

// 写入二进制文件
async function saveImage(data) {
  const bytes = new Uint8Array(data);
  await writeFile('output.png', bytes, {
    baseDir: BaseDirectory.Picture,
  });
}

7.6 目录操作

import {
  readDir,
  mkdir,
  remove,
  exists,
  BaseDirectory,
} from '@tauri-apps/plugin-fs';

// 读取目录
async function listDirectory() {
  const entries = await readDir('projects', {
    baseDir: BaseDirectory.Home,
  });

  for (const entry of entries) {
    console.log(`${entry.isDirectory ? '[DIR]' : '[FILE]'} ${entry.name}`);
  }
}

// 创建目录
async function createProject(name) {
  const projectPath = `projects/${name}`;

  if (!(await exists(projectPath, { baseDir: BaseDirectory.Home }))) {
    await mkdir(projectPath, {
      baseDir: BaseDirectory.Home,
      recursive: true,
    });
  }
}

// 删除目录
async function deleteProject(name) {
  const projectPath = `projects/${name}`;

  if (await exists(projectPath, { baseDir: BaseDirectory.Home })) {
    await remove(projectPath, {
      baseDir: BaseDirectory.Home,
      recursive: true,
    });
  }
}

7.7 文件操作

import {
  copyFile,
  rename,
  truncate,
  stat,
  exists,
  remove,
  BaseDirectory,
} from '@tauri-apps/plugin-fs';

// 复制文件
async function backupFile() {
  await copyFile('data.db', 'data.db.bak', {
    fromPathBaseDir: BaseDirectory.AppData,
    toPathBaseDir: BaseDirectory.AppData,
  });
}

// 重命名文件
async function renameFile(oldName, newName) {
  await rename(oldName, newName, {
    fromPathBaseDir: BaseDirectory.AppData,
    toPathBaseDir: BaseDirectory.AppData,
  });
}

// 获取文件元数据
async function getFileInfo(filename) {
  const metadata = await stat(filename, {
    baseDir: BaseDirectory.AppData,
  });

  console.log('Size:', metadata.size);
  console.log('Created:', metadata.created);
  console.log('Modified:', metadata.modified);
  console.log('Is Directory:', metadata.isDirectory);
}

// 截断文件
async function clearLogFile() {
  await truncate('app.log', 0, {
    baseDir: BaseDirectory.AppLog,
  });
}

// 删除文件
async function deleteFile(filename) {
  if (await exists(filename, { baseDir: BaseDirectory.AppData })) {
    await remove(filename, {
      baseDir: BaseDirectory.AppData,
    });
  }
}

7.8 监视文件变化

需要启用 watch 功能:

# Cargo.toml
[dependencies]
tauri-plugin-fs = { version = "2", features = ["watch"] }
import { watch, watchImmediate, BaseDirectory } from '@tauri-apps/plugin-fs';

// 防抖监视(适合文件编辑场景)
async function watchConfigFile() {
  await watch(
    'config.json',
    (event) => {
      console.log('Event type:', event.type);
      console.log('Paths:', event.paths);
    },
    {
      baseDir: BaseDirectory.AppConfig,
      delayMs: 500,
    }
  );
}

// 即时监视(适合日志文件等实时更新场景)
async function watchLogFile() {
  await watchImmediate(
    'app.log',
    (event) => {
      console.log('Log file changed:', event);
    },
    {
      baseDir: BaseDirectory.AppLog,
      recursive: true,
    }
  );
}

7.9 使用 open 和 create 函数

import { open, create, BaseDirectory } from '@tauri-apps/plugin-fs';

// 以写模式打开文件
async function appendToFile() {
  const file = await open('notes.txt', {
    write: true,
    append: true,
    baseDir: BaseDirectory.Document,
  });

  await file.write(new TextEncoder().encode('New content\n'));
  await file.close();
}

// 使用 create 创建新文件
async function createNewFile() {
  const file = await create('newfile.txt', {
    baseDir: BaseDirectory.Desktop,
  });

  await file.write(new TextEncoder().encode('Hello, World!'));
  await file.close();
}

// 以只读模式打开并读取
async function readFileWithOpen() {
  const file = await open('report.pdf', {
    read: true,
    baseDir: BaseDirectory.Document,
  });

  const stat = await file.stat();
  const buffer = new Uint8Array(stat.size);
  await file.read(buffer);
  await file.close();

  return buffer;
}

八、HTTP 请求处理

8.1 插件安装与初始化

HTTP 功能通过 tauri-plugin-http 提供:

cargo add tauri-plugin-http

Rust 端初始化:

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_http::init())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

安装 JavaScript 包:

npm install @tauri-apps/plugin-http

8.2 权限配置

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "http-capability",
  "windows": ["main"],
  "permissions": [
    "http:default",
    {
      "identifier": "http:allow-fetch",
      "allow": [
        { "url": "https://api.example.com" },
        { "url": "https://*.github.com" },
        { "url": "https://jsonplaceholder.typicode.com" }
      ]
    }
  ]
}

8.3 基础 GET 请求

import { fetch } from '@tauri-apps/plugin-http';

async function getUsers() {
  const response = await fetch('https://jsonplaceholder.typicode.com/users', {
    method: 'GET',
  });

  if (response.ok) {
    const users = await response.json();
    return users;
  } else {
    throw new Error(`HTTP error! status: ${response.status}`);
  }
}

8.4 POST 请求

async function createUser(userData) {
  const response = await fetch('https://jsonplaceholder.typicode.com/users', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(userData),
  });

  return await response.json();
}

8.5 带认证的请求

async function fetchWithAuth() {
  const response = await fetch('https://api.example.com/protected', {
    method: 'GET',
    headers: {
      'Authorization': 'Bearer your-token-here',
      'Accept': 'application/json',
    },
  });

  if (response.status === 401) {
    // Token 过期,需要刷新
    await refreshToken();
    return fetchWithAuth();
  }

  return await response.json();
}

8.6 处理不同响应类型

async function handleVariousResponses() {
  const response = await fetch('https://api.example.com/data', {
    method: 'GET',
  });

  const contentType = response.headers.get('content-type');

  if (contentType.includes('application/json')) {
    return await response.json();
  } else if (contentType.includes('text/')) {
    return await response.text();
  } else if (contentType.includes('application/octet-stream')) {
    return await response.arrayBuffer();
  } else {
    // 处理其他类型
    return await response.blob();
  }
}

8.7 文件上传

async function uploadFile(file, uploadUrl) {
  const formData = new FormData();
  formData.append('file', file);
  formData.append('filename', file.name);

  const response = await fetch(uploadUrl, {
    method: 'POST',
    body: formData,
  });

  return await response.json();
}

8.8 Rust 端使用 reqwest

在 Rust 端可以直接使用 reqwest 库:

use tauri_plugin_http::reqwest;

#[tauri::command]
async fn fetch_api_data() -> Result<String, String> {
    let response = reqwest::get("https://api.example.com/data")
        .await
        .map_err(|e| e.to_string())?;

    let text = response.text()
        .await
        .map_err(|e| e.to_string())?;

    Ok(text)
}

8.9 处理网络错误

async function robustFetch(url, options = {}) {
  const maxRetries = 3;
  let lastError;

  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetch(url, options);

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      return await response.json();
    } catch (error) {
      lastError = error;
      console.warn(`Attempt ${i + 1} failed:`, error);

      if (i < maxRetries - 1) {
        await new Promise(resolve =>
          setTimeout(resolve, 1000 * (i + 1))
        );
      }
    }
  }

  throw new Error(`All attempts failed: ${lastError.message}`);
}

8.10 启用不安全请求头

某些请求头(如 Authorization)在默认情况下被禁止。如需启用,需要在 Cargo.toml 中启用 unsafe-headers 特性:

[dependencies]
tauri-plugin-http = { version = "2", features = ["unsafe-headers"] }

九、总结与最佳实践

9.1 项目结构建议

一个成熟的 Tauri v2 项目应该采用清晰的模块化结构。前端代码组织应遵循所选框架的最佳实践,Rust 后端建议按功能模块划分,将命令、事件处理、插件初始化等分离到独立文件中。

9.2 安全考虑

Tauri v2 的权限系统是保障应用安全的关键。开发者应遵循最小权限原则,仅授予应用实际需要的权限。路径访问应限制在必要的目录范围内,网络请求应明确限定允许的域名列表。所有用户输入都应在 Rust 后端进行验证和清理。

9.3 性能优化建议

异步命令应优先使用 async fn 语法以避免阻塞事件循环。大量数据传输推荐使用 Channel 而非直接返回值。文件操作应考虑批量处理以减少 I/O 次数。窗口创建和销毁应谨慎管理,避免频繁创建销毁导致的资源消耗。

9.4 跨平台兼容性

虽然 Tauri 抽象了大部分平台差异,但某些功能(如透明标题栏、文件路径格式)仍需要平台特定处理。建议使用条件编译处理平台相关代码,并通过检测运行时环境提供合适的回退方案。

我把本地 AI Chat 项目重构了一遍:用 LangChain.js + Ollama + Streamdown 搭了一个最小可扩展架构

作者 倾颜
2026年3月23日 18:22

我把本地 AI Chat 项目重构了一遍:用 LangChain.js + Ollama + Streamdown 搭了一个最小可扩展架构

这篇文章记录一次“从可用原型走向可维护架构”的过程。

目标不是一上来堆满能力,而是在改动范围可控的前提下,把一个本地聊天项目的几个核心层重新梳理清楚:

  • 大模型集成层:LangChain.js + Ollama
  • 内容渲染标准:Markdown + typed parts + Streamdown
  • 前端交互层:自定义 useChatStream Hook,只做最小多轮上下文
  • 输入与协议校验:Zod

如果你也在做自己的 AI 应用原型,或者正准备把一个“能跑”的 Demo 往“能持续迭代”的方向收一收,这篇内容应该会比较有参考价值。

项目前端截图.png


一、为什么要重构,而不是继续往上加功能?

很多 AI 项目的第一版都会很像:

  • 前端一个输入框
  • 后端直接调大模型接口
  • 返回一段字符串
  • 页面上把字符串渲染出来

这个阶段追求的是“先跑起来”,完全没问题。

但项目只要继续做,就会很快遇到几个问题:

  1. 模型接入层过于直连 代码里直接写死 Ollama 请求,后面要加推理模式、工具调用、结构化输出,服务端会越来越重。

  2. 前后端协议太薄 如果接口只有一个 prompt -> answer,那后面要支持“推理内容”和“最终答案”分开展示、支持来源、支持卡片化数据,都会很别扭。

  3. 前端状态和流式处理容易失控 自己手写流式读取并不难,但如果没有清晰的消息模型,后面加取消、重试、多轮上下文、推理区块,很容易越写越乱。

  4. 渲染层缺少统一标准 如果模型输出今天是纯文本,明天是 Markdown,后天又要支持 reasoning/source/table,前端很容易到处写分支判断。

所以这次重构,我给自己的目标很明确:

不追求一次做满,而是先把“架构骨架”搭对。


二、这次方案怎么定?

最终落地的技术组合是下面这套:

前端页面
  └─ useChatStream
      └─ /api/chat
          └─ Zod 校验
              └─ LangChain.js
                  └─ ChatOllama
                      └─ Ollama

返回内容
  └─ NDJSON 流
      └─ typed parts
          ├─ reasoning
          └─ text

前端渲染
  └─ Streamdown 渲染 Markdown

这套方案有几个关键词:

  • LangChain.js:用来统一模型接入层
  • Ollama:本地模型运行时
  • typed parts:统一消息内容结构
  • Streamdown:专门面向流式 Markdown 的渲染器
  • useChatStream:我们自己维护的最小聊天 Hook
  • Zod:把请求和流式协议都校验起来

注意,这里我没有引入 AI SDK。

不是 AI SDK 不好,而是当前这个阶段我更希望:

  • 控制抽象层数
  • 看清楚流式协议到底怎么跑
  • 保留足够简单的代码结构,便于后续写博客和总结经验

三、架构改造后,核心边界怎么划分?

这次重构,我把项目拆成了四个层次。

1. 模型接入层:LangChain.js + Ollama

这一层只负责一件事:

把“业务消息”送给模型,并把模型流式输出转换成前端能消费的协议。

为什么不用前端直接打 Ollama?

  • 模型密钥和地址不应该暴露在浏览器
  • 推理流拆分、异常兜底、取消传递都更适合在服务端做
  • 后续如果从 Ollama 切到其它 provider,改动面更小

2. 消息模型层:typed parts

这一层是我认为这次改造里最重要的一层。

我没有继续把消息定义成一整段字符串,而是改成:

export interface TextPart {
  type: 'text'
  text: string
  format: 'markdown'
}

export interface ReasoningPart {
  type: 'reasoning'
  text: string
  format: 'markdown'
  visibility?: 'collapsed' | 'expanded' | 'hidden'
}

export type MindMessagePart = TextPart | ReasoningPart

这样做的意义很大:

  • textreasoning 在结构上天然分离
  • 前端渲染时不需要再从一大段字符串里“猜”哪部分是推理
  • 后面如果要加 sourcetooltablecard,只需要继续扩展 part 类型

这就是“最小可扩展”的核心思路。

3. 传输协议层:NDJSON 流

这次没有上 SSE 的复杂协议,也没有直接绑定某个框架的消息协议,而是用了一层轻量 NDJSON。

原因很简单:

  • 它足够轻
  • 浏览器和服务端都很好处理
  • 很适合自己掌控流式细节

为了支持推理内容和正文拆分,我把流式 chunk 扩成了这样:

export type ChatStreamChunk =
  | { type: 'start'; messageId: string }
  | { type: 'reasoning-start'; partId: string }
  | { type: 'reasoning-delta'; partId: string; delta: string }
  | { type: 'reasoning-end'; partId: string }
  | { type: 'text-start'; partId: string }
  | { type: 'text-delta'; partId: string; delta: string }
  | { type: 'text-end'; partId: string }
  | { type: 'finish' }
  | { type: 'error'; message: string }

你可以把它理解成:

  • 先告诉前端“我要开始一条 assistant 消息了”
  • 再分别告诉前端“推理内容开始了”“答案正文开始了”
  • 然后按 chunk 追加文本

这个协议不复杂,但非常清晰。

4. 前端渲染层:Streamdown + Tailwind CSS

这一层只负责把 part 渲染出来。

  • text part 用 Streamdown 渲染 Markdown
  • reasoning part 用折叠区展示
  • 页面样式统一交给 Tailwind CSS

这一层的重点不是“UI 有多花”,而是:

把消息结构和渲染职责严格分开。


四、模型接入层为什么选 LangChain.js + Ollama?

1. LangChain.js 的价值,不是“更炫”,而是“更稳的抽象”

这次重构前,模型调用可以直接 fetch Ollama。

但我还是把服务端接入层换成了 LangChain.js + ChatOllama,原因主要有三个:

第一,模型消息结构更统一

前端传来的消息数组,可以在服务端先转成 LangChain message:

export function toLangChainMessages(messages: MindMessageInput[]): BaseMessage[] {
  const result: BaseMessage[] = []

  for (const message of messages) {
    const content = message.parts
      .filter(part => part.type === 'text')
      .map(part => part.text)
      .join('\n\n')
      .trim()

    if (!content) continue

    switch (message.role) {
      case 'system':
        result.push(new SystemMessage(content))
        break
      case 'assistant':
        result.push(new AIMessage(content))
        break
      default:
        result.push(new HumanMessage(content))
    }
  }

  return result
}

这个适配器很小,但意义很大:

  • 项目内部用自己的 MindMessage
  • 模型层用 LangChain 的标准消息
  • 两边职责分离,后面升级不会互相污染
第二,推理能力接入更自然

这次我需要支持“推理内容”和“最终答案”分开展示。

ChatOllama 在开启 think: true 后,会把 reasoning 放到 additional_kwargs.reasoning_content 里。

所以服务端就可以这样拆:

const model = new ChatOllama({
  model: request.options?.model ?? deps.defaultModel,
  baseUrl: deps.baseUrl ?? process.env.OLLAMA_BASE_URL ?? 'http://127.0.0.1:11434',
  temperature: request.options?.temperature ?? 0.3,
  numPredict: request.options?.maxTokens,
  think: request.options?.enableReasoning,
  streaming: true,
})

const modelStream = await model.stream(langChainMessages, {
  signal: context.signal,
})

后面只要从 chunk 里分别提 reasoning_content 和正文内容即可。

第三,后续扩展空间更好

当前我只做了最小聊天链路,但 LangChain 的好处是:

  • 后续要加 tool calling,可以继续往上接
  • 要加 structured output,也能顺着现在这层演进
  • 要从 Ollama 切到其他模型,也不至于重写整个服务层

从架构角度看,这就是典型的“先把边界站稳”。


五、为什么内容渲染标准一定要定成 Markdown + typed parts?

这是这次方案里我最想强调的一点。

很多 AI 项目初期都会图方便:

  • 模型直接返回一大段字符串
  • 前端直接渲染成 Markdown

这么做短期当然可以,但一旦出现这些需求,就会开始痛苦:

  • 我只想把推理过程折叠起来
  • 我只想让来源单独展示
  • 我希望表格和引用做特殊渲染
  • 我希望后面支持工具结果卡片

如果消息只是一个大字符串,那所有能力都只能靠“字符串解析”,会越来越脆。

所以我这次直接把消息模型定成:

export interface MindMessage {
  id: string
  role: 'system' | 'user' | 'assistant'
  parts: MindMessagePart[]
  createdAt: string
}

也就是说:

  • 消息是容器
  • 内容是 parts

当前只落了两种 part:

  • text
  • reasoning

但架构上已经为后续扩展留下位置了。

这类设计在 AI 应用里非常值得早做,因为它会直接影响后面所有能力的演进方式。


六、服务端怎么把“推理”和“答案”拆成两条流?

这是这次改造最关键的实现点之一。

在服务端,我把 LangChain 的模型流再包了一层,转成自己的 NDJSON 协议。

核心思路是:

  1. 每次请求先生成一条 assistant message
  2. 推理和正文分别拥有自己的 partId
  3. 收到 reasoning 就发 reasoning-delta
  4. 收到正文就发 text-delta

关键代码如下:

for await (const chunk of modelStream) {
  if (context.signal?.aborted || closed) {
    return
  }

  const reasoning = getReasoningText(chunk)
  const text = getChunkText(chunk)

  if (reasoning) {
    ensureReasoningPartStarted()
    writeChunk({
      type: 'reasoning-delta',
      partId: reasoningPartId,
      delta: reasoning,
    })
  }

  if (text) {
    ensureTextPartStarted()
    writeChunk({
      type: 'text-delta',
      partId: textPartId,
      delta: text,
    })
  }
}

这里有几个实现要点。

要点 1:不要把 reasoning 和 text 混成一个 part

这是协议设计的核心。

如果这里偷懒,直接把所有 token 都拼到一段正文里,前端后面就很难再做“推理折叠”和“答案正文”分区。

要点 2:用 partId 确保前端合并正确

为什么还要多一个 partId

因为在流式场景下,前端不是一次拿到完整内容,而是一段一段增量拼接。

所以:

  • messageId 用于定位是哪条 assistant 消息
  • partId 用于定位当前增量属于消息里的哪一个 part

这其实是一个很经典的流式协议设计细节。

要点 3:取消请求必须一路向下传

服务端不是只把浏览器连接关掉,而是把 AbortSignal 传给模型调用:

const modelStream = await model.stream(langChainMessages, {
  signal: context.signal,
})

这样用户在前端点“停止”时,服务端和模型层都能一起停下来。

这对本地模型尤其重要,不然很容易出现:

  • 前端停了
  • Ollama 还在后台继续跑

七、前端为什么保留自定义 useChatStream,而不是再上一个更重的抽象?

这是这次方案一个很有意识的取舍。

我没有上更完整的聊天 SDK,而是保留了一个自己维护的 useChatStream

原因是:

  • 当前功能面不大
  • 我需要真正掌握协议细节
  • 想把代码控制在“够用 + 清晰”的范围里

1. 多轮上下文怎么做?

方案非常简单:

  • 前端维护 messages[]
  • 每次发消息时,把当前历史消息一起发给 /api/chat
  • 服务端转成 LangChain 消息后送给模型

这就是最小多轮上下文。

代码也很直接:

const payload: ChatRequest = {
  conversationId: conversationIdRef.current,
  messages: nextMessages.map(toMessageInput),
  options: {
    model: DEFAULT_MODEL,
    enableReasoning: true,
  },
}

2. 为什么只回传 text,不回传 reasoning

这是这次方案里一个很关键的取舍。

在前端把消息转成请求体时,我只保留 text

function toMessageInput(message: MindMessage): MindMessageInput {
  return {
    role: message.role,
    parts: message.parts.filter(
      (part): part is MindMessageInput['parts'][number] => part.type === 'text'
    ),
  }
}

这么做的原因是:

  • reasoning 更像中间推理过程,不一定适合反复喂回模型
  • 最小实现阶段,保留“用户问题 + 助手答案正文”的上下文就够了
  • 这样上下文更干净,也更稳定

这是一种典型的“先保守设计,再逐步开放能力”的思路。

3. Hook 怎么处理流式增量?

前端在读取 NDJSON 后,会根据 chunk 类型把内容追加到不同 part 中:

case 'reasoning-delta':
  updateMessages(current =>
    appendPartDelta(current, activeStreamRef.current.messageId ?? '', 'reasoning', chunk.delta)
  )
  return

case 'text-delta':
  updateMessages(current =>
    appendPartDelta(current, activeStreamRef.current.messageId ?? '', 'text', chunk.delta)
  )
  return

这里的设计好处是:

  • Hook 只负责“消息状态机”
  • 组件只负责“如何展示”
  • 逻辑层和视图层没有拧在一起

八、为什么要用 Zod 做输入和协议校验?

AI 项目里有一个很常见的问题:

大家都在关注模型输出,却经常忽略“接口边界”。

但实际上,一旦是流式场景,协议只要有一个 chunk 不符合预期,前端状态就很容易乱掉。

所以这次我把 Zod 用在了两个地方。

1. 请求入口校验

后端 /api/chat 收到请求后,不是直接拿来用,而是先过 schema:

const json = await request.json()
const payload = chatRequestSchema.parse(json)

对应 schema:

export const chatRequestSchema = z.object({
  conversationId: z.string().min(1),
  messages: z.array(messageInputSchema).min(1),
  options: z
    .object({
      model: z.string().optional(),
      temperature: z.number().optional(),
      maxTokens: z.number().int().positive().optional(),
      enableReasoning: z.boolean().optional(),
    })
    .optional(),
})

这一步的价值在于:

  • 请求结构一眼就清楚
  • 非法请求可以明确返回 400
  • 后续演进字段时心里更有底

2. 流式 chunk 校验

前端读取 NDJSON 后,也不是直接用,而是逐行做 schema 校验:

const parsedChunk = chatStreamChunkSchema.safeParse(JSON.parse(trimmedLine))

if (!parsedChunk.success) {
  throw new Error('Invalid chat stream chunk.')
}

这一步非常关键。

因为它能防止:

  • 后端协议升级但前端没同步
  • 某个 chunk 缺字段
  • 某个字段类型写错

从工程角度看,Zod 在这里充当的是“运行时契约”的角色。

这对于 AI 项目尤其重要,因为流式协议一旦不稳定,问题往往很难排查。


九、Markdown 渲染为什么选 Streamdown,而不是普通 Markdown 渲染器?

如果只是静态 Markdown,普通渲染器也能用。

但 AI 聊天有个典型特征:

内容是流式长出来的,而不是一次性到齐的。

这就意味着渲染器必须能接受:

  • 还没闭合的代码块
  • 还没结束的列表
  • 还没完整收尾的表格

这也是为什么我选了 Streamdown

基础用法其实很简单

export function TextPartView({ part }: { part: TextPart }) {
  return (
    <div className="markdown-body text-[15px] leading-7 text-inherit">
      <Streamdown>{part.text}</Streamdown>
    </div>
  )
}

但真正要注意的是 Tailwind v4 的接入细节

这里踩了一个很典型的坑。

如果项目接入了 Tailwind CSS v4,而你直接用了 Streamdown,却没有补这两样东西:

  1. @source "../node_modules/streamdown/dist/*.js"
  2. streamdown 需要的设计变量

那么很容易出现:

  • 代码块样式异常
  • 表格边框不对
  • 工具条结构错位

所以最终我的全局样式是这样处理的:

@import "tailwindcss";
@import "streamdown/styles.css";

@source "../node_modules/streamdown/dist/*.js";

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.145 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --border: oklch(0.922 0 0);
  --input: oklch(0.922 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --radius: 0.875rem;
}

这段配置非常值得单独记一下,因为它不是“页面美化”,而是 Streamdown 正常工作所需的运行条件


十、UI 层这次为什么顺手接了 Tailwind CSS?

虽然这次重点不是样式,但我还是把页面样式从内联 style 收到了 Tailwind CSS。

原因主要是:

  • 组件结构更清晰
  • 样式和组件更贴近
  • 后面写博客、调 UI、扩展页面时更轻

比如页面布局现在就是比较典型的聊天结构:

<main className="min-h-screen ...">
  <div className="mx-auto flex min-h-screen max-w-5xl flex-col gap-6 px-6 pb-7 pt-10">
    <header>...</header>
    <ChatMessageList messages={messages} status={status} />
    <div className="sticky bottom-0 ...">
      <ChatInputForm ... />
    </div>
  </div>
</main>

这个阶段我没有追求复杂交互,而是只把几个体验做稳:

  • 用户消息气泡和助手正文分离
  • 输入框固定在底部
  • 流式生成时保留轻量 loading
  • 推理内容默认折叠

对于原型阶段来说,这已经足够了。


十一、这套方案最适合什么阶段?

如果你现在的项目还处在下面这个阶段:

  • 想把本地 AI 聊天先跑稳
  • 想理解流式协议和前后端边界
  • 不想一开始就引入过多框架抽象
  • 但又希望未来能继续扩展

那这套方案其实很适合。

它的特点是:

优点

  • 技术边界清晰
  • 抽象层数适中
  • 代码量可控
  • 非常适合个人实践和写总结
  • 对后续扩展友好

目前刻意保留的简化点

  • 只做本地多轮上下文,不做持久化
  • 只支持 textreasoning 两种 part
  • 不做 RAG、不做工具调用、不做结构化卡片
  • 前端仍然是自定义 Hook,不追求全家桶式能力

换句话说,这是一套:

“现在够用,未来能长”的最小架构。


十二、最后总结:这次重构真正解决了什么?

如果只从功能角度看,这次看起来像是在做:

  • 接 LangChain
  • 支持推理流
  • 加 Tailwind
  • 换一个 Markdown 渲染器

但如果从架构角度看,它真正解决的是四件事:

1. 把模型层和业务消息层解耦了

项目内部用 MindMessage,模型层用 LangChain message,边界清晰。

2. 把“回答内容”从“单字符串”升级成了“结构化 parts”

后面继续加来源、工具结果、卡片渲染时,不需要推翻现有模型。

3. 把流式输出变成了一套可维护协议

前后端都知道:

  • 哪个 chunk 是推理
  • 哪个 chunk 是答案
  • 该怎么合并

4. 把前端状态控制在了最小闭环

useChatStream 没有追求大而全,但已经把这几个关键能力兜住了:

  • 多轮上下文
  • 流式增量
  • 取消请求
  • 错误处理
  • part 合并

对我来说,这就是这次重构最有价值的地方。

不是功能一下子变多了,而是以后再继续做的时候,不用每一步都重新拆地基。


结语

AI 应用开发很容易掉进一个误区:

只盯着模型能力,却忽略工程结构。

但真正能让项目走得更远的,往往不是“今天又接了哪个模型”,而是:

  • 你的消息协议是不是清晰
  • 你的模型接入层是不是可替换
  • 你的前端状态是不是可维护
  • 你的渲染标准是不是可扩展

这次我给这个本地聊天项目做的,其实就是这样一次“从 Demo 到架构雏形”的整理。

如果后面继续往下做,我比较看好的下一步会是:

  1. 增加 source part
  2. 做会话持久化
  3. 增加 tool calling
  4. 再考虑是否引入更完整的消息层 SDK

如果你也在做类似的项目,希望这篇文章能帮你少走一点弯路。

📦 完整代码

本博客对应的代码已发布 v0.0.4 版本:

👉 GitHub Release - v0.0.4

如果对你有帮助的话,可以点个Star!

在Web3前端用Node.js子进程批量校验钱包,我踩了这些性能与安全的坑

作者 竹林818
2026年3月23日 18:12

背景

上个月,我接了一个NFT项目的后台管理工具开发。项目方需要定期向社区贡献者空投NFT,他们有一个包含3000-5000个钱包地址的Excel表格。我的任务是:在前端页面上传这个表格后,快速校验所有地址的有效性,并过滤出无效地址

听起来简单,但实际做起来才发现坑不少。最初我用ethers.jsisAddress函数写了个简单的循环:

// 最初的天真版本
const validateAddresses = (addresses: string[]) => {
  const results = [];
  for (const addr of addresses) {
    results.push({
      address: addr,
      isValid: ethers.isAddress(addr)
    });
  }
  return results;
};

问题来了:当地址数量超过1000个时,页面直接卡死,控制台警告"长任务阻塞主线程"。用户得等上10多秒才能看到结果,体验极差。而且,如果校验过程中用户进行其他操作,整个页面都会卡顿。

问题分析

我首先想到的是Web Worker——浏览器端的多线程方案。我创建了一个Worker文件,把校验逻辑放进去:

// worker.ts
self.onmessage = (e) => {
  const { addresses } = e.data;
  const results = addresses.map(addr => ({
    address: addr,
    isValid: ethers.isAddress(addr)
  }));
  self.postMessage(results);
};

这确实解决了主线程阻塞的问题,但带来了新问题:

  1. 内存泄漏:每次校验都创建新的Worker实例,旧实例没有正确销毁
  2. 性能瓶颈:单个Worker处理5000个地址仍需3-4秒
  3. 依赖问题:Worker中无法直接使用项目中的ethers实例,需要重新初始化

更麻烦的是,我还需要校验地址是否在特定链上存在(通过RPC查询余额),这涉及异步网络请求,在Worker中处理起来更复杂。

这里有个关键发现:我开发的是Electron应用(项目方要求桌面端),这意味着我可以使用Node.js的全部能力,包括child_process多进程。这比Web Worker更强大,每个子进程有独立的内存空间,崩溃不会影响主进程。

核心实现

1. 设计进程通信协议

我决定采用"进程池"模式:创建固定数量的子进程,每个进程处理一批地址。首先需要设计进程间的通信协议:

// types.ts
export interface ValidationTask {
  taskId: string;
  addresses: string[];
  chainId: number;
  rpcUrl: string;
}

export interface ValidationResult {
  taskId: string;
  results: Array<{
    address: string;
    isValid: boolean;
    hasBalance?: boolean;
    error?: string;
  }>;
  processId: number;
}

注意这个细节:每个任务都有唯一的taskId,因为多个任务可能同时进行,需要区分返回结果属于哪个任务。

2. 实现子进程脚本

子进程脚本需要独立运行,我创建了validator-process.ts

// validator-process.ts
import { ethers } from 'ethers';
import type { ValidationTask, ValidationResult } from './types';

// 初始化provider,每个进程独立实例
let provider: ethers.JsonRpcProvider | null = null;

const validateBatch = async (task: ValidationTask): Promise<ValidationResult> => {
  const results = [];
  
  for (const address of task.addresses) {
    try {
      // 基础格式校验
      const isValid = ethers.isAddress(address);
      
      let hasBalance = false;
      // 如果地址格式有效,进一步检查链上余额
      if (isValid && provider) {
        try {
          const balance = await provider.getBalance(address);
          hasBalance = !balance.isZero();
        } catch (error) {
          // RPC调用失败,不影响格式校验结果
          console.error(`RPC查询失败: ${address}`, error);
        }
      }
      
      results.push({
        address,
        isValid,
        hasBalance,
        ...(hasBalance === undefined && { error: 'RPC查询失败' })
      });
    } catch (error) {
      results.push({
        address,
        isValid: false,
        error: error instanceof Error ? error.message : '未知错误'
      });
    }
  }
  
  return {
    taskId: task.taskId,
    results,
    processId: process.pid // 返回进程ID用于监控
  };
};

// 监听父进程消息
process.on('message', async (task: ValidationTask) => {
  try {
    // 延迟初始化provider,避免进程启动时就连接RPC
    if (!provider && task.rpcUrl) {
      provider = new ethers.JsonRpcProvider(task.rpcUrl, task.chainId, {
        staticNetwork: true
      });
    }
    
    const result = await validateBatch(task);
    process.send!(result);
  } catch (error) {
    // 确保错误信息也能返回给父进程
    process.send!({
      taskId: task.taskId,
      results: [],
      processId: process.pid,
      error: error instanceof Error ? error.message : '进程执行错误'
    });
  }
});

// 处理未捕获异常,防止进程静默崩溃
process.on('uncaughtException', (error) => {
  console.error('子进程未捕获异常:', error);
  process.exit(1);
});

这里有个坑:子进程中的console.log输出在Electron中默认看不到,我后来通过ipcRenderer重定向到了渲染进程的console。

3. 创建进程池管理器

在主进程(Node.js端)创建进程池管理器:

// process-pool.ts
import { fork, ChildProcess } from 'child_process';
import path from 'path';
import { EventEmitter } from 'events';

export class ValidatorProcessPool extends EventEmitter {
  private processes: ChildProcess[] = [];
  private taskQueue: Array<{
    task: any;
    resolve: (value: any) => void;
    reject: (reason: any) => void;
  }> = [];
  private busyProcesses = new Set<number>();
  
  constructor(
    private poolSize: number = 4, // 默认4个进程,根据CPU核心数调整
    private scriptPath: string = path.join(__dirname, 'validator-process.js')
  ) {
    super();
    this.initPool();
  }
  
  private initPool() {
    for (let i = 0; i < this.poolSize; i++) {
      const child = fork(this.scriptPath, [], {
        stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
        // 重要:设置进程内存限制,防止单个进程占用过多内存
        execArgv: ['--max-old-space-size=512']
      });
      
      child.on('message', (result) => {
        const pid = child.pid!;
        this.busyProcesses.delete(pid);
        this.emit('taskComplete', { pid, result });
        this.processNextTask();
      });
      
      child.on('exit', (code) => {
        console.warn(`子进程 ${child.pid} 退出,代码: ${code}`);
        // 重启进程
        this.restartProcess(child);
      });
      
      child.on('error', (error) => {
        console.error(`子进程错误:`, error);
      });
      
      this.processes.push(child);
    }
  }
  
  private getAvailableProcess(): ChildProcess | null {
    return this.processes.find(p => !this.busyProcesses.has(p.pid!)) || null;
  }
  
  private processNextTask() {
    if (this.taskQueue.length === 0) return;
    
    const availableProcess = this.getAvailableProcess();
    if (!availableProcess) return;
    
    const { task, resolve, reject } = this.taskQueue.shift()!;
    const pid = availableProcess.pid!;
    this.busyProcesses.add(pid);
    
    // 设置超时,防止任务卡死
    const timeout = setTimeout(() => {
      this.busyProcesses.delete(pid);
      reject(new Error(`任务超时: ${task.taskId}`));
      this.processNextTask();
    }, 30000); // 30秒超时
    
    availableProcess.once('message', (result) => {
      clearTimeout(timeout);
      this.busyProcesses.delete(pid);
      resolve(result);
    });
    
    availableProcess.send(task);
  }
  
  public submitTask(task: any): Promise<any> {
    return new Promise((resolve, reject) => {
      this.taskQueue.push({ task, resolve, reject });
      this.processNextTask();
    });
  }
  
  private restartProcess(oldProcess: ChildProcess) {
    const index = this.processes.indexOf(oldProcess);
    if (index > -1) {
      this.processes.splice(index, 1);
      this.busyProcesses.delete(oldProcess.pid!);
      
      const newProcess = fork(this.scriptPath, [], {
        stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
        execArgv: ['--max-old-space-size=512']
      });
      
      // 复制事件监听器...
      this.processes.push(newProcess);
    }
  }
  
  public async shutdown() {
    // 优雅关闭:先完成队列中的任务
    while (this.taskQueue.length > 0) {
      await new Promise(resolve => setTimeout(resolve, 100));
    }
    
    // 终止所有子进程
    for (const process of this.processes) {
      process.kill('SIGTERM');
    }
  }
}

关键优化:我设置了--max-old-space-size=512限制每个进程最大内存为512MB,防止单个地址列表过大导致内存溢出。

4. 前端集成与进度展示

在React组件中集成进程池,并显示实时进度:

// AddressValidator.tsx
import React, { useState, useRef, useEffect } from 'react';
import { ValidatorProcessPool } from './process-pool';

const AddressValidator: React.FC = () => {
  const [progress, setProgress] = useState(0);
  const [results, setResults] = useState<any[]>([]);
  const [isProcessing, setIsProcessing] = useState(false);
  const processPoolRef = useRef<ValidatorProcessPool | null>(null);
  
  useEffect(() => {
    // 初始化进程池
    processPoolRef.current = new ValidatorProcessPool(
      navigator.hardwareConcurrency || 4
    );
    
    return () => {
      // 组件卸载时清理
      processPoolRef.current?.shutdown();
    };
  }, []);
  
  const validateAddresses = async (addresses: string[]) => {
    if (!processPoolRef.current) return;
    
    setIsProcessing(true);
    setProgress(0);
    setResults([]);
    
    const batchSize = 100; // 每批处理100个地址
    const batches = [];
    
    // 分割成多个批次
    for (let i = 0; i < addresses.length; i += batchSize) {
      batches.push(addresses.slice(i, i + batchSize));
    }
    
    const allResults = [];
    
    // 使用Promise.all并发提交任务,但进程池会控制并发数
    const tasks = batches.map((batch, index) => ({
      taskId: `batch-${index}-${Date.now()}`,
      addresses: batch,
      chainId: 1, // Ethereum主网
      rpcUrl: process.env.REACT_APP_RPC_URL!
    }));
    
    // 监听进度
    let completed = 0;
    const total = tasks.length;
    
    for (const task of tasks) {
      try {
        const result = await processPoolRef.current.submitTask(task);
        allResults.push(...result.results);
        completed++;
        setProgress(Math.round((completed / total) * 100));
      } catch (error) {
        console.error('批次处理失败:', error);
      }
    }
    
    setResults(allResults);
    setIsProcessing(false);
    
    // 统计结果
    const validCount = allResults.filter(r => r.isValid).length;
    const hasBalanceCount = allResults.filter(r => r.hasBalance).length;
    console.log(`校验完成: ${validCount}个有效地址,${hasBalanceCount}个有余额`);
  };
  
  // 渲染组件...
};

5. 错误处理与重试机制

在实际运行中,我发现RPC调用有时会失败,需要重试机制:

// 在子进程脚本中添加重试逻辑
const queryBalanceWithRetry = async (
  provider: ethers.JsonRpcProvider, 
  address: string,
  maxRetries = 3
): Promise<boolean> => {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const balance = await provider.getBalance(address);
      return !balance.isZero();
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      // 指数退避重试
      await new Promise(resolve => 
        setTimeout(resolve, 1000 * Math.pow(2, i))
      );
    }
  }
  return false;
};

完整代码

由于完整代码较长,这里提供核心部分的整合版本。实际项目需要安装依赖:ethers@^6.0.0@types/node

项目结构:

src/
  ├── main/           # Electron主进程
  ├── renderer/       # React渲染进程
  │   ├── components/
  │   │   └── AddressValidator.tsx
  │   └── utils/
  │       ├── process-pool.ts
  │       ├── validator-process.ts
  │       └── types.ts
  └── shared/         # 共享类型

关键整合点:在Electron的主进程中暴露进程池API给渲染进程:

// main/ipc-handlers.ts
import { ipcMain } from 'electron';
import { ValidatorProcessPool } from '../renderer/utils/process-pool';

let processPool: ValidatorProcessPool | null = null;

ipcMain.handle('init-validator-pool', (event, poolSize) => {
  if (!processPool) {
    processPool = new ValidatorProcessPool(poolSize);
  }
  return true;
});

ipcMain.handle('validate-addresses', async (event, task) => {
  if (!processPool) {
    throw new Error('进程池未初始化');
  }
  return await processPool.submitTask(task);
});

ipcMain.handle('shutdown-pool', async () => {
  if (processPool) {
    await processPool.shutdown();
    processPool = null;
  }
});

踩坑记录

  1. 坑1:进程间通信丢失

    • 现象:有时子进程返回结果后,父进程收不到消息
    • 原因:Electron中渲染进程不能直接创建子进程,需要通过主进程转发
    • 解决:所有子进程操作放在主进程,通过IPC与渲染进程通信
  2. 坑2:内存泄漏

    • 现象:长时间运行后内存持续增长
    • 原因:子进程中的ethers.js Provider会缓存请求,没有清理
    • 解决:定期重启子进程,并在每个任务完成后手动清除缓存
  3. 坑3:RPC速率限制

    • 现象:批量查询余额时频繁被RPC节点拒绝
    • 原因:多个进程同时请求,超过节点速率限制
    • 解决:在进程池级别添加请求队列,控制整体请求频率
  4. 坑4:进程僵尸

    • 现象:子进程异常退出后变成僵尸进程
    • 原因:没有正确处理SIGTERM信号
    • 解决:在子进程脚本中添加信号处理,确保资源释放

小结

通过这次实战,我深刻理解了在前端(特别是Electron)中使用多进程处理计算密集型任务的完整流程。核心收获是:合理划分任务粒度、设计健壮的进程通信协议、充分考虑错误恢复机制。这个方案将5000个地址的校验时间从15秒缩短到0.8秒,并且页面完全无卡顿。

未来可以继续优化:实现动态进程池(根据负载自动扩容缩容)、添加更详细的内存监控、支持WebSocket实时进度推送。对于纯浏览器环境,可以考虑改用WebAssembly版本的地址校验库来避免子进程的复杂性。

1-umi-前端工程化搭建

2026年3月23日 17:39

vscode 插件推荐

  • eslint
  • prettier
  • typescript
  • GitLens

创建项目文件夹

  • mkdir umi-monorepo
  • cd umi-monorepo
  • pnpm init -y 初始化项目配置

创建基础目录结构

umi-monorepo/
├── packages/  项目目录
├── examples/  示例项目目录
└── scripts/   脚本目录

根目录下创建pnpm-workspace.yaml文件

packages:
  # 包目录
  - 'packages/*'
  # 示例项目目录
  - 'examples'

根目录下创建 .gitignore文件

# Dependencies
node_modules/
.pnpm-store/

# Build outputs
dist/
lib/
es/

# Environment files
.env
.env.local

# IDE files
.vscode/
.idea/

# OS files
.DS_Store
Thumbs.db

# Logs
*.log

创建Typescript项目

  • 安装: pnpm i -Dw typescript
  • 初始化TS配置: pnpm tsc --init
    • 生成一个 tsconfig.base.json 文件
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "node",// 指定模块解析策略
    "noEmit": false,// 不生成任何文件
    "allowJs": true,// 允许编译 js 文件
    "skipLibCheck": true,// 跳过库文件的类型检查
    "esModuleInterop": true,// 允许从没有设置默认导出的模块中默认导入
    "experimentalDecorators": true,// 允许使用实验性的装饰器
    "strict": true,// 启用所有严格类型检查选项
    "forceConsistentCasingInFileNames": true,// 禁止对同一个文件的不一致的引用
    "noImplicitReturns": true,// 在所有函数中,返回值类型必须与函数的返回值类型一致
    "declaration": true,// 生成相应的.d.ts文件
    "resolveJsonModule": true// 允许从 json 文件中导入数据
  },
  "exclude": ["node_modules", "lib"]
}

prettier: 代码格式化

  • 安装: pnpm i -Dw prettier
    • -Dw: 安装依赖并保存到根目录下package.json devDependencies 中
  • 根目录下创建 .prettierrc文件
  • 安装依赖:pnpm i -Dw prettier-plugin-organize-imports prettier-plugin-packagejson
    • prettier-plugin-organize-imports: 插件,自动排序 import
    • prettier-plugin-packagejson: 插件,格式化 package.json(专用于标准化 package.json 文件的属性顺序)
{
  "printWidth": 80,// 每行字符数
  "singleQuote": true,// 使用单引号
  "trailingComma": "none",// 对象字面量中的最后一个属性的结尾添加逗号
  "proseWrap": "never",// 当行字符数超过 printWidth 时,换行方式,默认为 hard wrap,可选值:never | always | preserve
  "overrides": [{ "files": ".prettierrc", "options": { "parser": "json" } }],
  "plugins": ["prettier-plugin-organize-imports", "prettier-plugin-packagejson"]// 格式化时候引入的插件
}

.prettierignore: 忽略需要格式化的文件

node_modules/
lib/
dist/
coverage/
pnpm-lock.yaml

package.json添加格式化脚本

{
  "scripts": {
    "format": "prettier --write .",// 格式化所有文件
    "format:check": "prettier --check ."// 检查所有文件是否格式化
  }
}
  • 测试命令
    • pnpm format: 格式化所有文件
    • pnpm format:check:检查所有文件是否格式化

ESLint: 代码规范(这个现在也可以用oxlint代替)

  • 安装: pnpm i -Dw eslint@8.45.0
  • 根目录下创建:.eslinrc.js文件,配置如下:
module.exports = {
  'root': true, // 根目录
  'parser': '@typescript-eslint/parser',// 指定解析器
  'plugins': ['@typescript-eslint'],// 指定插件
  'rules': {
    'no-var': 'error',// 不能使用var声明变量
    'no-extra-semi': 'error',// 禁止不必要的分号
    '@typescript-eslint/indent': ['error', 2],// 缩进2个空格
    'import/extensions': 'off',// 导入文件时,忽略扩展名
    'linebreak-style': [0, 'error', 'windows'],// 换行符使用\r\n
    'indent': ['error', 2, { SwitchCase: 1 }], // error类型,缩进2个空格
    'space-before-function-paren': 0, // 在函数左括号的前面是否有空格
    'eol-last': 0, // 不检测新文件末尾是否有空行
    'semi': ['error', 'always'], // 在语句后面加分号
    'quotes': ['error', 'single'],// 字符串使用单双引号,double,single
    'no-console': ['error', { allow: ['log', 'warn'] }],// 允许使用console.log()
    'arrow-parens': 0,// 箭头函数参数是否使用括号
    'no-new': 0,//允许使用 new 关键字
    'comma-dangle': [2, 'never'], // 数组和对象键值对最后一个逗号, never参数:不能带末尾的逗号, always参数:必须带末尾的逗号,always-multiline多行模式必须带逗号,单行模式不能带逗号
    'no-undef': 0// 不能有未定义的变量
  },
  'parserOptions': {// 解析器配置
    'ecmaVersion': 6,// ECMAScript版本
    'sourceType': 'module',// 模块类型
    'ecmaFeatures': {// 特性配置
      'modules': true// 支持模块
    }
  }
};

安装typescript-eslint

  • pnpm i -Dw @typescript-eslint/eslint-plugin@6.2.0 @typescript-eslint/parser@6.2.0

添加eslint脚本

{
  "scripts": {
    "lint": "eslint packages/**/*.{ts,tsx,js,jsx} examples/**/*.{ts,tsx,js,jsx} --fix --no-error-on-unmatched-pattern",
    "lint:check": "eslint packages/**/*.{ts,tsx,js,jsx} examples/**/*.{ts,tsx,js,jsx} --no-error-on-unmatched-pattern"
  }
}
  • 测试命令:pnpm lint:check

git: 提交规范(commitlint)

安装commitlint

  • pnpm i -Dw @commitlint/cli @commitlint/config-conventional

根目录初始化配置文件

  • pnpm commitlint --init-> 出现 commitlint.config.js文件,修改内容如下:
module.exports = {extends: ['@commitlint/config-conventional']}

安装commitizen(可选)

  • 帮助生成规范的提交信息
  • pnpm i -Dw commitizen cz-conventional-changelog
  • 修改package.json文件
{
  "scripts": {
    "commit": "git cz"
  },
  "config": {
    "commitizen": {
      "path": "./node_modules/cz-conventional-changelog"
    }
  }
}

安装husky

  • 用来检测git提交信息
  • 安装: pnpm i -Dw husky
  • 初始化 husky
# 初始化husky
pnpm exec husky init
# 初始化后会生成一个 .husky文件夹

# 设置prepare脚本,确保其他人clone后也能自动安装hooks
npm pkg set scripts.prepare="husky install"

.husky在添加commit-msg钩子(文件): 用来检测提交信息

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

pnpm exec commitlint --edit $1
  • 安装:lint-staged -> 用于在提交前检查代码格式
  • pnpm i -Dw lint-staged
  • 修改package.json文件
{
  "lint-staged": {// 添加lint-staged配置
    "*.{ts,tsx,js,jsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,md}": [
      "prettier --write"
    ]
  }
}
  • 在根目录创建.husky/pre-commit文件,内容如下:
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

pnpm lint:check // 这里就是commit前执行package.json中的这个lint:check命令
  • 提交代码时候
    • pre-commit hook 会运行 pnpm lint:check
      • 如果 lint:check 成功,则 git commit
    • commit-msg hook 会验证提交信息是否符合 conventional commit 格式(通过 commitlint)
      • 如果 commitlint 验证通过,则 git commit

Father: 构建工具

  • 用来构建项目,支持多包管理,支持monorepo
  • 安装:pnpm i -w father
  • 根目录创建.father.config.ts文件,内容如下:
import { defineConfig } from 'father';

export default defineConfig({// 这里是father的配置
  cjs: {// cjs配置
    output: 'lib',// 输出目录
  },
});

changesets版本管理

  • 用来管理版本,支持monorepo
  • 安装:pnpm i -Dw @changesets/cli
  • 初始化:pnpm exec changeset init -> 创建.changeset文件夹
  • 后面在package.json中添加版本管理相关scripts

进入exmaples目录

  • 我们开始试验一下
  • 执行:pnpm dlx create-umi@latest
  • 创建 .umirc.ts
  • 创建成功,cd ..,进入根目录
import { defineConfig } from '@umijs/max';

export default defineConfig({
  antd: {},
  access: {},
  model: {},
  initialState: {},
  request: {},
  layout: {
    title: 'Umi Examples',
  },
  routes: [
    { path: '/', redirect: '/home' },
    { name: '首页', path: '/home', component: './Home' },
  ],
  npmClient: 'pnpm',
  plugins: [
    // 这里将会添加我们以后开发的插件
  ],
});

整个项目目录

umi-plugins-monorepo/
├── .husky/                  # git hooks
│   ├── commit-msg
│   └── pre-commit
├── examples/                # 示例项目
│   ├── src/
│   ├── .umirc.ts
│   └── package.json
├── packages/                # 插件包目录
│   └── tsconfig.base.json
├── scripts/                 # 脚本目录
├── .eslintrc.js            # ESLint配置
├── .fatherrc.base.ts       # Father构建配置
├── .gitignore              # Git忽略文件
├── .prettierrc             # Prettier配置
├── .prettierignore         # Prettier忽略文件
├── commitlint.config.js    # Commitlint配置
├── package.json            # 根package.json
├── pnpm-workspace.yaml     # pnpm workspace配置

整个项目的package.json文件如下:

{
  "name": "umi-monorepo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "directories": {
    "example": "examples"
  },
  "scripts": {
    "commit": "git cz",
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "lint": "eslint packages/**/*.{ts,tsx,js,jsx} examples/**/*.{ts,tsx,js,jsx} --fix --no-error-on-unmatched-pattern",
    "lint:check": "eslint packages/**/*.{ts,tsx,js,jsx} examples/**/*.{ts,tsx,js,jsx} --no-error-on-unmatched-pattern",
    "prepare": "husky install"
  },
  "config": {
    "commitizen": {
      "path": "./node_modules/cz-conventional-changelog"
    }
  },
  "lint-staged": {
    "*.ts": [
      "eslint --fix",
      "git add"
    ]
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@changesets/cli": "^2.29.5",
    "@commitlint/cli": "^19.8.1",
    "@commitlint/config-conventional": "^19.8.1",
    "@typescript-eslint/eslint-plugin": "6.2.0",
    "@typescript-eslint/parser": "6.2.0",
    "@umijs/lint": "4.4.2",
    "commitizen": "^4.3.1",
    "cz-conventional-changelog": "^3.3.0",
    "eslint": "8.45.0",
    "husky": "^9.1.7",
    "lint-staged": "^16.1.2",
    "prettier": "^3.6.2",
    "prettier-plugin-organize-imports": "^4.2.0",
    "prettier-plugin-packagejson": "^2.5.19",
    "typescript": "^5.8.3"
  },
  "dependencies": {
    "father": "^4.6.1"
  }
}

关于对echart盒子设置百分比读取的宽高没有撑开盒子解决方案

作者 真夜
2026年3月23日 17:39

问题背景

当使用ecaht时,对echart对盒子使用百分比高宽。

在echart源码当中,调用init函数会自动创建canvas 默认设置画布高宽为当前盒子的高宽。

image.png

但这需要设定的dom盒子有具体的px像素盒子 例如

   <div ref="echart_pie1" style="width: 100px;height: 100px;" />
   
   
    const myEcharts = echarts.init(echart_pie1.value, null);

如果dom宽高设置百分比会导致渲染时,宽高并没有撑开。

dom设置百分白宽高时候的情况 image.png

解决方案

1.setTimeout(() => {
        const myEcharts = echarts.init(echart_pie1.value, null);
        myEcharts.setOption(option);
    }, 0)
    
    
  2.await nextTick()
    // setTimeout(() => {
    const myEcharts = echarts.init(echart_pie1.value, null);
    myEcharts.setOption(option);

核心原理,使用异步任务推漫创建canvas

别再让 AI 盲写代码了:我用 gstack 把"灵感"变"可上线"

作者 jerrywus
2026年3月23日 17:31

不知道你有没有这种感觉:让 AI 写功能,10 分钟出了 500 行代码。一跑,全是报错。再让它修,修着修着,最初要做什么都忘了。

我以前也这样。后来换了个思路——不是让 AI 直接当码农,而是先让它当产品、架构、设计、测试、发布的搭子,评审完了再动手。

这个搭子就是 gstack (同时支持claudecode/codex)

gstack 是什么

把 AI 编程比作盖房子的话:

  • 普通用法:直接往上垒砖
  • gstack 用法:先出施工图,审过了再开工

说白了就是:把"写代码"前置成"做决策",把返工扼杀在动手之前。

我在用的 AI 开发流水线

这套我跑了有一阵子了,对"有想法、想快、又怕翻车"的场景特别管用。

1)需求澄清——/office-hours

/office-hours

它会逼你回答几个问题:

  • 你到底在解决谁的问题?
  • 第一版最小闭环是什么?
  • 你要快上线,还是要做理想架构?

看起来只是多问了两句,但真的能帮你省掉后面一堆返工。

实际效果截图:

image.png

阶段性提问结束,会让你选择方案:

image.png

2)用 CEO 视角挑战方案——/plan-ceo-review

/plan-ceo-review

这步特别适合避免"做了很多,但没什么价值"的情况。

它会帮你做三件事:

  • 逼你想清楚这到底是不是在解真问题
  • 给你 2~3 套路线(最小可行 / 理想架构 / 创意路线)
  • 把 scope 拆成:现在做、延后做、别做

你会第一次感受到:AI 不只是"会写",还会"删"。

实际效果截图:

image.png

3)工程门禁——/plan-eng-review

/plan-eng-review

这是"技术债隔离带"。它会盯几件事:

  • 接口契约稳不稳(schema、错误码)
  • 异常能不能降级,不是只会 console.error
  • 测试有没有覆盖分支,不是只测 happy path
  • 发布能不能回滚,不是靠祈祷

不是能跑就行,是要可维护、可观测、可回滚。

实际效果截图:

image.png

4)设计门禁——/plan-design-review

/plan-design-review

这步特别容易被跳过去,但体验差距往往就出在这里:

  • 信息层级对不对(先看啥、后看啥)
  • 状态文案全不全(loading、error、fallback)
  • 移动端交互合不合理
  • 可访问性达没达到基线(键盘、读屏、对比度)

UI 不是"好看"问题,是"用户敢不敢继续用"问题。

实际效果截图:

image.png

5)实现阶段

这时候你手里已经有了:

  • 明确范围(哪些做、哪些不做)
  • 工程规范(契约、错误、测试、发布)
  • 设计规范(层级、状态、交互)

让 AI 写代码,基本就是"按图施工"。

如果你中途想退出的话,可以先让它更新下文档状态,然后后续再继续。gstack生成的记忆文档在~/.gstack/projects/[your-project-name]目录下:

image.png

6)上线前质量收口——/qa + /review

/qa

该指令会:测试你的应用,找出漏洞,用原子提交修复它们,然后重新验证。每次修复都会自动生成回归测试。

/review

该指令会:找出那些通过持续集成测试但在生产环境中崩溃的缺陷。自动修复显而易见的缺陷。标记出完整性方面的不足。


当然,你可以只执行:/qa-only

  • /qa:系统化找 bug
  • /review:PR 风险审查

7)发布——/ship + /land-and-deploy + /canary

这个没实际使用过,感兴趣的话,可以自己试试。

命令速查表

阶段 命令 作用
需求澄清 /office-hours 把想法变成可执行设计
战略校准 /plan-ceo-review 挑战问题与范围,做取舍
工程门禁 /plan-eng-review 架构/错误/测试/发布过审
设计门禁 /plan-design-review 交互与状态体验过审
QA /qa / /qa-only 系统化测试并修复
代码审查 /review 预合并风险检查
发版 /ship 打包发布流程
落地+监控 /land-and-deploy + /canary 合并部署+线上观测

几个建议

建议 1:AI 写得快,不代表你该跳过评审。 越快,越要有门禁。

建议 2:先定"错误如何失败",再写功能。 很多线上事故不是功能错,是失败方式错。静默失败、状态假成功、不可回滚——这些才是问题。

建议 3:把"延期项"写进文档,不要写进脑子。TODOS.mddocs/designs/xxx.md。脑子会忘,文档不会。

我的工作习惯

每个新功能我都会先问自己三个问题:

  1. 这个功能的设计文档在哪?
  2. CEO/Eng/Design 三轮评审结论有没有写进仓库?
  3. 没写清楚之前,是不是又在让 AI 硬写代码?

三句有一句答不上来,我就不开工。

写在最后

以前觉得 AI 编程的上限是"写得快"。现在觉得不是——它真正的上限是:让你做对决策,再把正确的事做快。

如果你也在被"写得飞快、改得崩溃"折磨,可以试试 gstack 这套流程。AI 不再像临时工,而像有组织的团队。

RAG 资料库 Demo 完整开发流程

作者 楠木top
2026年3月23日 17:19

目录

  1. 项目概述
  2. 技术选型
  3. 架构设计
  4. 环境准备
  5. 项目初始化
  6. 核心功能实现
  7. 前端界面
  8. 问题排查与解决
  9. 启动与使用
  10. 扩展与优化

项目概述

什么是 RAG?

RAG(Retrieval-Augmented Generation) 是一种结合了信息检索和生成式 AI 的技术:

  1. 检索阶段:从知识库中找到与用户问题最相关的文档片段
  2. 生成阶段:将这些片段作为上下文,让 LLM 生成准确的回答

项目目标

构建一个完整的 RAG 系统 Demo,支持:

  • ✅ 上传 PDF 和 Word 文档
  • ✅ 自动分块和向量化
  • ✅ 向量数据库存储和检索
  • ✅ 基于检索结果的智能问答
  • ✅ 展示引用来源

技术选型

后端框架

组件 选择 原因
Web 框架 Koa.js 轻量、中间件模式清晰、异步友好
向量数据库 Qdrant 高性能、支持云端、API 简洁
Embedding 模型 阿里云 DashScope text-embedding-v3 中文支持好、成本低、维度 1024
LLM OpenAI gpt-4o-mini 性能稳定、成本合理
文档解析 pdf-parse + mammoth 支持 PDF 和 Word
文本分块 LangChain.js RecursiveCharacterTextSplitter 按语义分块,保留上下文
文件上传 @koa/multer Koa 官方中间件

前端

组件 选择 原因
框架 原生 HTML/CSS/JS 无依赖、快速加载、易于部署
样式 CSS 变量 + Flexbox 响应式、易于主题定制

架构设计

系统架构图

┌─────────────────────────────────────────────────────────────┐
│                      前端(浏览器)                           │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  上传区域          问答区域                            │   │
│  │  [拖拽上传]        [输入框] [发送]                     │   │
│  │  [文档列表]        [对话气泡]                          │   │
│  └──────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘
                            ↕ HTTP
┌─────────────────────────────────────────────────────────────┐
│                    Koa.js 后端服务                           │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  路由层                                               │   │
│  │  POST /api/upload    POST /api/query   GET /api/docs │   │
│  └──────────────────────────────────────────────────────┘   │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  业务逻辑层                                           │   │
│  │  ┌─────────────┐  ┌──────────────┐  ┌────────────┐  │   │
│  │  │ 文档解析    │  │ 文本分块     │  │ 向量化    │  │   │
│  │  │ (parser)    │  │ (splitter)   │  │ (embedder)│  │   │
│  │  └─────────────┘  └──────────────┘  └────────────┘  │   │
│  │  ┌─────────────┐  ┌──────────────┐  ┌────────────┐  │   │
│  │  │ 向量存储    │  │ 相似度检索   │  │ LLM 生成  │  │   │
│  │  │ (vectorStore)  │ (search)     │  │ (llm)     │  │   │
│  │  └─────────────┘  └──────────────┘  └────────────┘  │   │
│  └──────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘
         ↕                    ↕                    ↕
    ┌─────────┐          ┌──────────┐        ┌──────────┐
    │ 文件系统 │          │ Qdrant   │        │ OpenAI  │
    │ (uploads)          │ (向量库)  │        │ (LLM)   │
    └─────────┘          └──────────┘        └──────────┘

数据流

上传流程:

用户选择文件
    ↓
前端 FormData 上传
    ↓
后端接收 → 解析文档(PDF/Word)
    ↓
文本分块(RecursiveCharacterTextSplitter)
    ↓
批量向量化(阿里云 Embedding API)
    ↓
存入 Qdrant(带 metadata)
    ↓
返回成功信息 → 前端刷新文档列表

查询流程:

用户输入问题
    ↓
前端发送 JSON 请求
    ↓
后端向量化问题
    ↓
Qdrant 相似度检索(Top-K)
    ↓
提取相关文档片段
    ↓
拼接 Prompt + 上下文
    ↓
调用 OpenAI LLM
    ↓
返回回答 + 引用来源
    ↓
前端展示对话气泡 + 来源标注

环境准备

系统要求

  • Node.js: v18+(推荐 v22+)
  • npm/pnpm: 最新版本
  • Docker: 用于运行 Qdrant(可选,也支持云端 Qdrant)

必需的 API Key

  1. 阿里云 DashScope API Key

  2. OpenAI API Key

  3. Qdrant(选择一种)


项目初始化

1. 创建项目目录

mkdir rag-demo
cd rag-demo

2. 初始化 package.json

{
  "name": "rag-demo",
  "version": "1.0.0",
  "description": "RAG 资料库 Demo - Koa.js + Qdrant",
  "main": "src/app.js",
  "scripts": {
    "start": "node src/app.js",
    "dev": "node --watch src/app.js"
  },
  "dependencies": {
    "@koa/multer": "^3.0.2",
    "@koa/router": "^12.0.1",
    "@qdrant/js-client-rest": "^1.9.0",
    "dotenv": "^16.4.5",
    "koa": "^2.15.3",
    "koa-body": "^6.0.1",
    "koa-static": "^5.0.0",
    "mammoth": "^1.7.2",
    "multer": "^1.4.5-lts.1",
    "openai": "^4.47.1",
    "pdf-parse": "^1.1.1",
    "uuid": "^10.0.0",
    "@langchain/textsplitters": "^0.0.3"
  }
}

3. 创建目录结构

mkdir -p src/{routes,services,utils} public uploads

4. 创建 .env 配置文件

# ========== LLM 配置(OpenAI 兼容接口)==========
LLM_API_KEY=your_openai_api_key
LLM_BASE_URL=https://api.openai.com/v1
LLM_MODEL=gpt-4o-mini

# ========== Embedding 配置(阿里云 DashScope)==========
EMBEDDING_API_KEY=your_dashscope_api_key
EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
EMBEDDING_MODEL=text-embedding-v3

# ========== Qdrant 向量数据库 ==========
QDRANT_URL=http://localhost:6333
QDRANT_API_KEY=
QDRANT_COLLECTION=rag_docs_v2

# ========== RAG 参数 ==========
CHUNK_SIZE=500
CHUNK_OVERLAP=50
TOP_K=5
VECTOR_SIZE=1024

# ========== 服务 ==========
PORT=3000

核心功能实现

1. 应用入口(src/app.js)

require('dotenv').config();
const Koa = require('koa');
const Router = require('@koa/router');
const serve = require('koa-static');
const { koaBody } = require('koa-body');
const path = require('path');
const fs = require('fs');

const uploadRouter = require('./routes/upload');
const queryRouter = require('./routes/query');
const { ensureCollection } = require('./services/vectorStore');

const app = new Koa();
const router = new Router();

// 确保 uploads 目录存在
const uploadsDir = path.join(__dirname, '../uploads');
if (!fs.existsSync(uploadsDir)) {
  fs.mkdirSync(uploadsDir, { recursive: true });
}

// 错误处理中间件
app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    console.error('Server Error:', err);
    ctx.status = err.status || 500;
    ctx.body = { error: err.message || 'Internal Server Error' };
  }
});

// 解析 JSON body(必须在路由之前)
app.use(koaBody({ jsonLimit: '10mb' }));

// 静态文件(前端页面)
app.use(serve(path.join(__dirname, '../public')));

// 路由
router.use('/api', uploadRouter.routes(), uploadRouter.allowedMethods());
router.use('/api', queryRouter.routes(), queryRouter.allowedMethods());

app.use(router.routes());
app.use(router.allowedMethods());

const PORT = process.env.PORT || 3000;

// 启动时确保 Qdrant Collection 存在
ensureCollection()
  .then(() => {
    app.listen(PORT, () => {
      console.log(`🚀 RAG Demo 启动成功: http://localhost:${PORT}`);
      console.log(`📦 Qdrant: ${process.env.QDRANT_URL}`);
      console.log(`🤖 LLM: ${process.env.LLM_MODEL}`);
    });
  })
  .catch((err) => {
    console.error('❌ 启动失败,请检查 Qdrant 是否运行:', err.message);
    process.exit(1);
  });

关键点:

  • koaBody 中间件必须在路由之前加载,否则 POST 请求无法解析
  • 错误处理中间件捕获所有异常,返回 JSON 错误信息
  • 启动时自动创建 Qdrant Collection

2. 文档解析(src/services/parser.js)

const pdfParse = require('pdf-parse');
const mammoth = require('mammoth');
const fs = require('fs');

async function parseDocument(filePath, mimetype) {
  const buffer = fs.readFileSync(filePath);

  // PDF
  if (mimetype === 'application/pdf' || filePath.endsWith('.pdf')) {
    const data = await pdfParse(buffer);
    return data.text;
  }

  // Word (.docx / .doc)
  if (
    mimetype === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
    mimetype === 'application/msword' ||
    filePath.endsWith('.docx') ||
    filePath.endsWith('.doc')
  ) {
    const result = await mammoth.extractRawText({ buffer });
    return result.value;
  }

  throw new Error(`不支持的文件格式: ${mimetype}`);
}

module.exports = { parseDocument };

支持的格式:

  • PDF:使用 pdf-parse 提取文本
  • Word:使用 mammoth 提取文本(支持 .docx 和 .doc)

3. 文本分块(src/utils/splitter.js)

const { RecursiveCharacterTextSplitter } = require('@langchain/textsplitters');

const CHUNK_SIZE = parseInt(process.env.CHUNK_SIZE) || 500;
const CHUNK_OVERLAP = parseInt(process.env.CHUNK_OVERLAP) || 50;

async function splitText(text) {
  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: CHUNK_SIZE,
    chunkOverlap: CHUNK_OVERLAP,
  });

  const chunks = await splitter.splitText(text);
  return chunks;
}

module.exports = { splitText };

分块策略:

  • chunkSize=500:每个块约 500 个字符
  • chunkOverlap=50:块之间重叠 50 个字符,保留上下文
  • 使用 RecursiveCharacterTextSplitter 按语义边界分块(段落、句子)

4. 向量化(src/services/embedder.js)

const OpenAI = require('openai');

let client = null;

function getClient() {
  if (!client) {
    client = new OpenAI({
      apiKey: process.env.EMBEDDING_API_KEY,
      baseURL: process.env.EMBEDDING_BASE_URL || 'https://dashscope.aliyuncs.com/compatible-mode/v1',
    });
  }
  return client;
}

async function embed(input) {
  const openai = getClient();
  const model = process.env.EMBEDDING_MODEL || 'text-embedding-v3';

  const isArray = Array.isArray(input);
  const inputs = isArray ? input : [input];

  // 过滤空字符串
  const filtered = inputs.filter((t) => t && t.trim().length > 0);
  if (filtered.length === 0) {
    throw new Error('所有文本块为空,无法生成向量');
  }

  const response = await openai.embeddings.create({
    model,
    input: filtered,
  });

  if (!response || !response.data || response.data.length === 0) {
    throw new Error('Embedding API 返回空结果,请检查 EMBEDDING_API_KEY 是否正确');
  }

  const vectors = response.data.map((item) => item.embedding);

  // 处理空字符串填充
  if (filtered.length < inputs.length) {
    const result = [];
    let vecIdx = 0;
    for (const t of inputs) {
      if (t && t.trim().length > 0) {
        result.push(vectors[vecIdx++]);
      } else {
        result.push(new Array(vectors[0].length).fill(0));
      }
    }
    return isArray ? result : result[0];
  }

  return isArray ? vectors : vectors[0];
}

module.exports = { embed };

关键特性:

  • 支持单个文本或批量文本向量化
  • 过滤空字符串,避免 API 调用浪费
  • 错误处理完善,提示 API Key 问题

5. 向量存储(src/services/vectorStore.js)

const { QdrantClient } = require('@qdrant/js-client-rest');

let client = null;

function getClient() {
  if (!client) {
    client = new QdrantClient({
      url: process.env.QDRANT_URL || 'http://localhost:6333',
      apiKey: process.env.QDRANT_API_KEY || undefined,
    });
  }
  return client;
}

const COLLECTION = () => process.env.QDRANT_COLLECTION || 'rag_docs_v2';
const VECTOR_SIZE = parseInt(process.env.VECTOR_SIZE) || 1024;

async function ensureCollection() {
  const client = getClient();
  const name = COLLECTION();

  try {
    await client.getCollection(name);
    console.log(`✅ Qdrant Collection "${name}" 已存在`);
  } catch (e) {
    await client.createCollection(name, {
      vectors: {
        size: VECTOR_SIZE,
        distance: 'Cosine',
      },
    });
    console.log(`✅ Qdrant Collection "${name}" 创建成功`);
  }
}

async function upsertPoints(points) {
  const client = getClient();
  await client.upsert(COLLECTION(), {
    wait: true,
    points: points.map((p) => ({
      id: p.id,
      vector: p.vector,
      payload: p.payload,
    })),
  });
}

async function search(vector, topK) {
  const client = getClient();
  const k = topK || parseInt(process.env.TOP_K) || 5;

  const results = await client.search(COLLECTION(), {
    vector,
    limit: k,
    with_payload: true,
  });

  return results;
}

async function listDocuments() {
  const client = getClient();

  const result = await client.scroll(COLLECTION(), {
    limit: 1000,
    with_payload: true,
    with_vector: false,
  });

  const docsMap = new Map();
  for (const point of result.points) {
    const { docId, filename, uploadedAt, totalChunks } = point.payload;
    if (!docsMap.has(docId)) {
      docsMap.set(docId, { docId, filename, uploadedAt, totalChunks });
    }
  }

  return Array.from(docsMap.values()).sort(
    (a, b) => new Date(b.uploadedAt) - new Date(a.uploadedAt)
  );
}

module.exports = { ensureCollection, upsertPoints, search, listDocuments };

关键设计:

  • ensureCollection:启动时自动创建 Collection,支持幂等操作
  • upsertPoints:批量插入向量点,带 metadata
  • search:相似度检索,返回 Top-K 结果
  • listDocuments:去重获取已索引文档列表

6. LLM 生成(src/services/llm.js)

const OpenAI = require('openai');

let client = null;

function getClient() {
  if (!client) {
    client = new OpenAI({
      apiKey: process.env.LLM_API_KEY,
      baseURL: process.env.LLM_BASE_URL || 'https://api.openai.com/v1',
    });
  }
  return client;
}

async function generateAnswer(question, contexts) {
  const openai = getClient();
  const model = process.env.LLM_MODEL || 'gpt-4o-mini';

  const contextText = contexts
    .map((c, i) => `[来源 ${i + 1}: ${c.filename}]\n${c.text}`)
    .join('\n\n---\n\n');

  const systemPrompt = `你是一个专业的知识库问答助手。请根据以下提供的参考资料回答用户的问题。

规则:
1. 只根据提供的参考资料回答,不要编造信息
2. 如果参考资料中没有相关信息,请明确告知用户
3. 回答要简洁、准确、有条理
4. 可以适当引用来源文件名

参考资料:
${contextText}`;

  const response = await openai.chat.completions.create({
    model,
    messages: [
      { role: 'system', content: systemPrompt },
      { role: 'user', content: question },
    ],
    temperature: 0.3,
  });

  return response.choices[0].message.content;
}

module.exports = { generateAnswer };

Prompt 设计:

  • System Prompt 明确告诉 LLM 只基于提供的资料回答
  • 温度设置为 0.3,保证回答的一致性和准确性
  • 在 Prompt 中标注来源,便于追溯

7. 上传路由(src/routes/upload.js)

const Router = require('@koa/router');
const multer = require('@koa/multer');
const path = require('path');
const { v4: uuidv4 } = require('uuid');

const { parseDocument } = require('../services/parser');
const { splitText } = require('../utils/splitter');
const { embed } = require('../services/embedder');
const { upsertPoints, listDocuments } = require('../services/vectorStore');

const router = new Router();

// 修复中文文件名乱码
function fixFilename(filename) {
  try {
    if (!/[^\x00-\x7F]/.test(filename) || /[\u4e00-\u9fa5]/.test(filename)) {
      return filename;
    }
    return Buffer.from(filename, 'latin1').toString('utf8');
  } catch (e) {
    return filename;
  }
}

const upload = multer({
  storage: multer.diskStorage({
    destination: path.join(__dirname, '../../uploads'),
    filename: (req, file, cb) => {
      const fixedName = fixFilename(file.originalname);
      const uniqueName = `${uuidv4()}_${fixedName}`;
      cb(null, uniqueName);
    },
  }),
  limits: { fileSize: 50 * 1024 * 1024 },
  fileFilter: (req, file, cb) => {
    const fixedName = fixFilename(file.originalname);
    file.originalname = fixedName;

    const allowed = [
      'application/pdf',
      'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
      'application/msword',
    ];
    const allowedExt = ['.pdf', '.docx', '.doc'];
    const ext = path.extname(fixedName).toLowerCase();

    if (allowed.includes(file.mimetype) || allowedExt.includes(ext)) {
      cb(null, true);
    } else {
      cb(new Error('只支持 PDF 和 Word 文档'), false);
    }
  },
});

router.post('/upload', upload.single('file'), async (ctx) => {
  if (!ctx.file) {
    ctx.status = 400;
    ctx.body = { error: '请上传文件' };
    return;
  }

  const { filename, path: filePath, mimetype, originalname } = ctx.file;
  const docId = path.basename(filename, path.extname(filename)).split('_')[0];

  console.log(`📄 开始处理文档: ${originalname} (${docId})`);

  // 1. 解析文档
  let text;
  try {
    text = await parseDocument(filePath, mimetype);
  } catch (err) {
    ctx.status = 500;
    ctx.body = { error: `文档解析失败: ${err.message}` };
    return;
  }

  if (!text || text.trim().length < 10) {
    ctx.status = 400;
    ctx.body = { error: '文档内容为空或过短,无法索引' };
    return;
  }

  // 2. 文本分块
  const chunks = await splitText(text);
  console.log(`✂️ 文档分块完成: ${chunks.length} 个 chunk`);

  // 3. 批量向量化
  let vectors;
  try {
    vectors = await embed(chunks);
  } catch (err) {
    ctx.status = 500;
    ctx.body = { error: `向量化失败: ${err.message}` };
    return;
  }
  if (!vectors || vectors.length === 0) {
    ctx.status = 500;
    ctx.body = { error: '向量化返回空结果,请检查 EMBEDDING_API_KEY' };
    return;
  }
  console.log(`🔢 向量化完成: ${vectors.length} 个向量`);

  // 4. 存入 Qdrant(使用整数 ID)
  let pointIdCounter = Date.now();
  const points = chunks.map((text, idx) => ({
    id: pointIdCounter + idx,
    vector: vectors[idx],
    payload: {
      docId,
      filename: originalname,
      chunkIndex: idx,
      text,
      uploadedAt: new Date().toISOString(),
      totalChunks: chunks.length,
    },
  }));

  await upsertPoints(points);
  console.log(`✅ 文档已索引: ${originalname}`);

  ctx.body = {
    success: true,
    docId,
    filename: originalname,
    chunks: chunks.length,
  };
});

router.get('/docs', async (ctx) => {
  const docs = await listDocuments();
  ctx.body = { docs };
});

module.exports = router;

关键处理:

  • 中文文件名修复:从 Latin1 转回 UTF-8
  • 完整的错误处理链:解析 → 分块 → 向量化 → 存储
  • 使用时间戳 + 索引作为 Point ID,避免 UUID 格式问题

8. 查询路由(src/routes/query.js)

const Router = require('@koa/router');
const { embed } = require('../services/embedder');
const { search } = require('../services/vectorStore');
const { generateAnswer } = require('../services/llm');

const router = new Router();

router.post('/query', async (ctx) => {
  const { question, topK } = ctx.request.body;

  if (!question || question.trim().length === 0) {
    ctx.status = 400;
    ctx.body = { error: '请输入问题' };
    return;
  }

  const k = topK || parseInt(process.env.TOP_K) || 5;

  console.log(`🔍 问题: ${question}`);

  // 1. 问题向量化
  const questionVector = await embed(question);

  // 2. Qdrant 相似度检索
  const results = await search(questionVector, k);

  if (results.length === 0) {
    ctx.body = {
      answer: '当前知识库为空,请先上传文档后再提问。',
      sources: [],
    };
    return;
  }

  // 3. 提取上下文
  const contexts = results.map((r) => ({
    text: r.payload.text,
    filename: r.payload.filename,
    chunkIndex: r.payload.chunkIndex,
    score: r.score,
  }));

  console.log(`📚 检索到 ${contexts.length} 条相关文档片段`);

  // 4. LLM 生成回答
  const answer = await generateAnswer(question, contexts);

  ctx.body = {
    answer,
    sources: contexts,
  };
});

module.exports = router;

前端界面

HTML 结构(public/index.html)

前端采用单页应用设计,包含三个主要区域:

  1. 上传区域

    • 拖拽上传或点击选择
    • 实时显示上传状态
    • 已上传文档列表
  2. 问答区域

    • 对话气泡展示
    • 引用来源标注
    • 输入框和发送按钮
  3. 样式设计

    • CSS 变量管理主题色
    • Flexbox 响应式布局
    • 动画效果(加载、过渡)

关键 JavaScript 功能:

// 文件上传处理
async function handleUpload(file) {
  const formData = new FormData();
  formData.append('file', file);
  const res = await fetch(`${API}/upload`, { method: 'POST', body: formData });
  // ...
}

// 问答发送
async function sendQuestion() {
  const question = questionInput.value.trim();
  const res = await fetch(`${API}/query`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ question }),
  });
  // ...
}

问题排查与解决

问题 1:koaBody 中间件未加载

症状:

Cannot destructure property 'question' of 'ctx.request.body' as it is undefined

原因: 忘记在 app.js 中加载 koaBody 中间件

解决方案:

const { koaBody } = require('koa-body');
app.use(koaBody({ jsonLimit: '10mb' }));

关键: 中间件必须在路由之前加载

问题 2:向量维度不匹配

症状:

Vector dimension error: expected dim: 1536, got 1024

原因:

  • 旧 Collection 用 OpenAI Embedding(1536 维)创建
  • 新代码用阿里云 Embedding(1024 维)

解决方案:

  1. 删除旧 Collection:在 Qdrant Dashboard 中删除
  2. 改用新 Collection 名字:QDRANT_COLLECTION=rag_docs_v2
  3. 重启服务自动创建新 Collection

问题 3:Point ID 格式错误

症状:

value 966887ce-746d-4143-8d47-5cbd2076f271_0 is not a valid point ID

原因: Qdrant 要求 Point ID 是整数或标准 UUID,不支持自定义字符串

解决方案:

// ❌ 错误
id: `${docId}_${idx}`

// ✅ 正确
id: Date.now() + idx  // 整数 ID

问题 4:中文文件名乱码

症状:

文件名显示为:中文 (乱码)

原因: multer 默认用 Latin1 编码解析文件名

解决方案:

function fixFilename(filename) {
  try {
    if (!/[^\x00-\x7F]/.test(filename) || /[\u4e00-\u9fa5]/.test(filename)) {
      return filename;
    }
    return Buffer.from(filename, 'latin1').toString('utf8');
  } catch (e) {
    return filename;
  }
}

问题 5:fetch 请求头拼写错误

症状:

ctx.request.bodyundefined

原因: 前端 fetch 用了 header 而不是 headers

解决方案:

// ❌ 错误
fetch(url, { header: { 'Content-Type': 'application/json' } })

// ✅ 正确
fetch(url, { headers: { 'Content-Type': 'application/json' } })

启动与使用

1. 启动 Qdrant

本地 Docker:

docker run -p 6333:6333 -p 6334:6334 \
  -v $(pwd)/qdrant_storage:/qdrant/storage \
  qdrant/qdrant

云端:cloud.qdrant.io/ 创建实例,获取 URL 和 API Key

2. 配置 .env

LLM_API_KEY=sk-...
EMBEDDING_API_KEY=sk-...
QDRANT_URL=http://localhost:6333

3. 安装依赖

npm install

4. 启动服务

npm start

输出应该显示:

🚀 RAG Demo 启动成功: http://localhost:3000
📦 Qdrant: http://localhost:6333
🤖 LLM: gpt-4o-mini

5. 打开浏览器

访问 http://localhost:3000

6. 使用流程

  1. 上传文档

    • 拖拽或点击选择 PDF/Word 文件
    • 等待索引完成(显示 ✅)
    • 文档列表会自动刷新
  2. 提问

    • 在输入框输入问题
    • 按 Enter 或点击发送
    • 等待 LLM 生成回答
    • 查看引用来源

扩展与优化

1. 支持更多文档格式

添加 Markdown 支持:

// src/services/parser.js
if (filePath.endsWith('.md')) {
  return fs.readFileSync(filePath, 'utf-8');
}

添加 TXT 支持:

if (filePath.endsWith('.txt')) {
  return fs.readFileSync(filePath, 'utf-8');
}

2. 使用本地 Embedding 模型

用 Ollama + nomic-embed-text:

# 启动 Ollama
ollama run nomic-embed-text
// src/services/embedder.js
const response = await fetch('http://localhost:11434/api/embeddings', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    model: 'nomic-embed-text',
    prompt: input,
  }),
});
const data = await response.json();
return data.embedding;

3. 多知识库隔离

按用户或项目创建不同 Collection:

const COLLECTION = () => {
  const userId = ctx.state.userId; // 从认证中获取
  return `rag_docs_${userId}`;
};

4. 添加认证

使用 JWT:

npm install jsonwebtoken
// 中间件
app.use(async (ctx, next) => {
  const token = ctx.headers.authorization?.split(' ')[1];
  if (!token) {
    ctx.status = 401;
    ctx.body = { error: 'Unauthorized' };
    return;
  }
  // 验证 token...
  await next();
});

5. 添加数据库持久化

用 PostgreSQL 存储元数据:

npm install pg
const { Pool } = require('pg');
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

// 记录上传历史
await pool.query(
  'INSERT INTO uploads (docId, filename, uploadedAt) VALUES ($1, $2, $3)',
  [docId, originalname, new Date()]
);

6. 性能优化

批量向量化:

// 一次性向量化所有 chunks,而不是逐个
const vectors = await embed(chunks);

缓存 Qdrant 客户端:

let client = null;
function getClient() {
  if (!client) {
    client = new QdrantClient(...);
  }
  return client;
}

异步日志:

// 不阻塞主流程
setImmediate(() => {
  console.log(`📄 处理文档: ${originalname}`);
});

7. 监控和日志

添加结构化日志:

npm install winston
const logger = require('winston').createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' }),
  ],
});

logger.info('文档上传', { docId, filename, chunks: chunks.length });

8. 前端增强

添加进度条:

// 显示上传进度
xhr.upload.addEventListener('progress', (e) => {
  const percent = (e.loaded / e.total) * 100;
  progressBar.style.width = percent + '%';
});

添加搜索历史:

// localStorage 保存查询历史
localStorage.setItem('queries', JSON.stringify(queries));

添加主题切换:

// 深色/浅色模式
document.documentElement.setAttribute('data-theme', 'dark');

总结

这个 RAG Demo 展示了一个完整的从文档上传到智能问答的工作流:

  1. 文档处理:支持多种格式,自动解析和分块
  2. 向量化:使用专业 Embedding 模型,支持多厂商
  3. 存储检索:高性能向量数据库,毫秒级检索
  4. 智能生成:基于检索结果的准确回答,带来源标注
  5. 用户界面:简洁直观的前端交互

核心优势:

  • ✅ 模块化设计,易于扩展
  • ✅ 配置灵活,支持多厂商组合
  • ✅ 错误处理完善,生产就绪
  • ✅ 中文支持完整,无乱码问题
  • ✅ 前后端分离,易于部署

示例代码:

nanmu1.lanzoul.com/iyFoC3lbv0k…

【OSG学习笔记】Day 9: Switch类

作者 _李小白
2026年3月23日 16:42

去除图片水印 (1).png

OSwitch 开关节点

在 OpenSceneGraph(OSG)的场景管理体系中,osg::Switch(开关节点)是实现「子节点显隐控制」的核心组件——它能灵活控制场景中任意子节点的渲染状态(显示/隐藏),无需删除/重建节点,是实现「按需渲染」的轻量化方案。

本文将从核心特性、继承关系、实战代码三个维度,全面解析 Switch 节点的使用方法。

Switch 节点核心定位

osg::Switch 本质是 osg::Group 的子类,核心能力是为每个子节点绑定一个「布尔开关」:

  • 开关为 true:子节点参与场景渲染、遍历,正常显示;
  • 开关为 false:子节点被完全屏蔽(不渲染、不参与场景遍历);
  • 核心价值:动态控制节点可见性,无需修改场景树结构,性能开销极低(仅修改状态标记)。

典型应用场景:

  • 3D 场景中物体的「显示/隐藏」(如开关模型、UI 元素);
  • 多模型切换(如奶牛/滑翔机二选一显示);
  • 调试阶段临时屏蔽部分节点(如隐藏复杂模型,聚焦核心逻辑)。

Switch 节点继承关系

Switch 属于 OSG 组节点体系,完整继承链如下:

osg::Object 
  ↓ (所有OSG对象的根基类)
osg::Node 
  ↓ (所有场景节点的基类)
osg::Group 
  ↓ (组节点,可包含多个子节点)
osg::Switch 
  (开关节点,新增子节点显隐控制能力)

关键继承逻辑

  1. osg::Group 继承: 完全复用 Group 的核心能力(addChild()/removeChild() 管理子节点),仅新增「开关状态管理」;
  2. osg::Node 继承: 可直接加入场景树(如 root->addChild(switch.get())),符合 OSG 场景管理的统一规则;
  3. 轻量级扩展: Switch 仅在 Group 基础上增加「布尔状态数组」,内存/性能开销可忽略。

Switch 核心 API(常用操作)

API 方法 作用 示例
addChild(Node* child, bool visible) 添加子节点并设置初始可见性 switch->addChild(cow.get(), false)(隐藏奶牛)
setChildValue(Node* child, bool visible) 动态修改子节点可见性 switch->setChildValue(glider.get(), true)(显示滑翔机)
getChildValue(Node* child) 获取子节点当前可见性 bool isShow = switch->getChildValue(cow.get())
setSingleChildOn(int index) 仅显示指定索引的子节点(其余隐藏) switch->setSingleChildOn(1)(仅显示第2个子节点)
setAllChildrenOn() 显示所有子节点 switch->setAllChildrenOn()
setAllChildrenOff() 隐藏所有子节点 switch->setAllChildrenOff()

实战解析:Switch 节点控制奶牛/滑翔机显隐

结合前文的完整代码,我们拆解 Switch 节点的核心使用流程:

步骤1:加载待控制的模型节点

// 加载奶牛模型(待隐藏)
osg::ref_ptr<osg::Node> node1 = osgDB::readNodeFile("cow.osg");
// 加载滑翔机模型(待显示)
osg::ref_ptr<osg::Node> node2 = osgDB::readNodeFile("glider.osg");
  • 先加载需要切换显隐的两个模型,作为 Switch 节点的子节点。

步骤2:创建 Switch 节点并绑定子节点

// 创建 Switch 核心节点
osg::ref_ptr<osg::Switch> switchNode = new osg::Switch();

// 添加奶牛模型,设置为「隐藏」(第二个参数 false)
switchNode->addChild(node1.get(), false);
// 添加滑翔机模型,设置为「显示」(第二个参数 true)
switchNode->addChild(node2.get(), true);
  • 核心参数addChild 的第二个布尔值直接决定子节点初始状态;
  • get() 作用:从 osg::ref_ptr 中提取裸指针,满足 addChild 的参数要求(接收 osg::Node*)。

步骤3:将 Switch 节点加入场景树

// 根节点添加 Switch 节点(Switch 本身也是 Node 子类)
root->addChild(switchNode.get());
  • Switch 节点作为「控制容器」,只需将其加入场景树,即可自动管理子节点的显隐。

步骤4:运行程序验证效果

编译运行后,窗口中仅显示滑翔机,奶牛模型被完全隐藏——这正是 Switch 节点的核心作用:无需删除奶牛节点,仅通过开关状态屏蔽其渲染。

扩展:动态切换显隐(核心进阶用法)

若需在程序运行中切换模型显隐(如按键盘触发),只需调用 setChildValue

// 示例:隐藏滑翔机,显示奶牛
switchNode->setChildValue(node2.get(), false); // 隐藏滑翔机
switchNode->setChildValue(node1.get(), true);  // 显示奶牛
  • 无需重建场景树,仅修改开关状态,即可实时切换模型显示,性能无损耗。

关键对比:Switch 节点 vs 手动删除节点

很多新手会问:「直接删除/添加节点也能控制显隐,为什么用 Switch?」我们做核心对比:

维度 osg::Switch 节点 手动删除/添加节点
性能开销 极低(仅修改状态标记) 高(需重建场景树、重新优化)
复用性 子节点始终存在,可随时恢复显示 节点被删除后需重新加载/创建
开发成本 一行代码切换状态 需编写删除/添加逻辑,易出错
内存占用 子节点内存始终保留(按需渲染) 删除节点后内存释放,但恢复需重新加载

结论

  • 短期隐藏、需复用的节点:用 osg::Switch(最优选择);
  • 永久删除、无需复用的节点:手动删除(节省内存)。

完整可运行代码

源码路径:gitee switch

#include <osgViewer/Viewer>
#include <osg/Group>
#include <osg/Switch>
#include <osgDB/ReadFile>
#include <osgUtil/Optimizer>

int main() {
    osg::ref_ptr<osgViewer::Viewer> viewer = new osgViewer::Viewer();
    osg::ref_ptr<osg::Group> root = new osg::Group();

    // 加载模型
    osg::ref_ptr<osg::Node> cow = osgDB::readNodeFile("cow.osg");
    osg::ref_ptr<osg::Node> glider = osgDB::readNodeFile("glider.osg");

    // 创建 Switch 节点并控制显隐
    osg::ref_ptr<osg::Switch> switchNode = new osg::Switch();
    switchNode->addChild(cow.get(), false);    // 隐藏奶牛
    switchNode->addChild(glider.get(), true);  // 显示滑翔机

    // 构建场景
    root->addChild(switchNode.get());
    osgUtil::Optimizer optimizer;
    optimizer.optimize(root.get());

    // 渲染
    viewer->setSceneData(root.get());
    return viewer->run();
}

image.png

总结

  1. 核心定位osg::Switchosg::Group 的子类,通过「布尔开关」轻量级控制子节点显隐;
  2. 核心价值:无需修改场景树结构,动态切换节点可见性,性能开销极低;
  3. 实战关键addChild(child, visible) 设置初始状态,setChildValue 动态切换状态;
  4. 使用场景:模型切换、UI 显隐、调试屏蔽节点等「按需渲染」场景。

Switch 节点是 OSG 场景管理中最常用的「轻量化控制工具」,掌握它能大幅简化「节点显隐」的开发逻辑,避免不必要的场景树修改,提升程序稳定性与性能。

去除图片水印.png

使用 Next.js + Prisma + MySQL 开发全栈项目

作者 我的刀盾
2026年3月23日 16:38

本文介绍如何用 Next.jsPrismaMySQL 搭建全栈应用:包含 MySQL 与 MySQL Workbench 的安装与基本使用、创建项目、目录含义、数据库建模、Migrations(迁移) 的工作方式与命令,并附代码示例。


一、技术栈分工

技术 作用
Next.js React 框架:路由、SSR、服务端组件、Route Handlers(API)
Prisma ORM:类型安全查询、Schema 定义、迁移版本管理
MySQL 关系型数据库:表、外键、事务、索引

二、如何创建 Next.js 项目

2.1 前置条件

  • 已安装 Node.js(建议 LTS 版本)
  • 包管理器任选:npmpnpmyarn

2.2 使用官方脚手架

在项目父目录执行:

npx create-next-app@latest my-app

交互式选项说明(根据版本可能略有差异):

选项 建议 说明
TypeScript Yes 与 Prisma、大型项目更匹配
ESLint Yes 统一代码风格
Tailwind CSS 按需 UI 快速开发可选用
src/ directory 按需 若选 Yes,应用代码在 src/app
App Router Yes 当前推荐,与本文目录说明一致
Turbopack 按需 开发时可选更快的打包器

进入项目并安装依赖:

cd my-app
npm install

2.3 本地运行

npm run dev

浏览器访问 http://localhost:3000。默认端口为 3000

2.4 常用脚本(package.json

命令 作用
npm run dev 开发模式,热更新
npm run build 生产构建
npm run start 启动生产构建后的服务(需先 build
npm run lint 运行 ESLint

三、典型目录与文件说明(App Router)

以下以 未使用 src/ 的默认结构为例;若创建时选了 src/,则把 appcomponents 等放在 src/ 下即可。

my-app/
├── app/                      # App Router:页面、布局、路由
│   ├── layout.tsx            # 根布局(全局 HTML 壳、字体、Provider)
│   ├── page.tsx              # 路由 "/" 的首页
│   ├── globals.css           # 全局样式
│   ├── loading.tsx           # 可选:该路由段的加载 UI(Suspense)
│   ├── error.tsx             # 可选:该路由段的错误边界
│   └── api/                  # Route Handlers(HTTP API)
│       └── users/
│           └── route.ts      # 对应路径 /api/users
├── public/                   # 静态资源:favicon、图片等,URL 根路径直接访问
├── prisma/
│   ├── schema.prisma         # 数据模型与数据库连接配置
│   └── migrations/           # 迁移历史(由 prisma migrate 生成,勿手改 SQL 逻辑)
├── lib/                      # 常用:工具函数、Prisma 单例等(自建)
│   └── prisma.ts
├── components/               # 可复用 UI 组件(自建)
├── .env                      # 本地环境变量(勿提交密钥)
├── .env.example              # 环境变量示例(可提交)
├── next.config.ts / .js      # Next 构建与运行时配置
├── tsconfig.json             # TypeScript 配置
└── package.json

3.1 app/ 目录

  • page.tsx:该路由的页面 UI;文件夹名即 URL 路径段。
  • layout.tsx:嵌套布局,子路由共享外壳(导航栏、侧边栏等)。
  • route.ts:只处理 HTTP 方法(GET、POST 等),不渲染页面,用于 REST API
  • 默认导出为 Server Component,可直接 async 并访问数据库;需要浏览器事件、hooks 时在文件顶部加 "use client"

3.2 public/

放不需要经过打包处理的静态文件,例如 public/logo.png 对应访问路径 /logo.png

3.3 prisma/

  • schema.prisma:定义数据源、生成器、Model(表结构)。
  • migrations/:每次执行 prisma migrate dev(或生产用 deploy)产生的迁移记录,见下文「Migrations 详解」。

3.4 根目录配置文件

  • next.config.*:图片域名、重定向、实验特性等。
  • tsconfig.json:路径别名(如 @/*)常在这里配置,与 import 有关。

四、MySQL 安装与 MySQL Workbench

4.1 官方下载地址

以下均为 Oracle MySQL 官方站点,请从 Downloads 中选择操作系统与安装包类型。

软件 说明 下载页
MySQL Community Server 数据库服务本体(必装,供本地/服务器运行 MySQL) dev.mysql.com/downloads/m…
MySQL Installer(仅 Windows) 一站式安装器,可勾选 Server、Workbench、Shell 等 dev.mysql.com/downloads/i…
MySQL Workbench 图形化管理与 SQL 开发工具(可选但强烈推荐) dev.mysql.com/downloads/w…

说明

  • 若使用 MySQL Installer for Windows,通常一次勾选 MySQL Server + MySQL Workbench 即可,无需单独下载 Workbench。
  • macOS 可使用官网 DMG 或包管理器(如 Homebrew:brew install mysql);Linux 常用发行版仓库或官网 APT/YUM 仓库,Workbench 可单独安装。

4.2 在 Windows 上安装 MySQL Server(常见流程)

以下以 MySQL Installer 为例(适合本机开发):

  1. 打开 MySQL Installer 下载页,选择 Windows (x86, 64-bit), MSI Installer(体积较大的完整安装包或在线安装包均可)。
  2. 运行安装程序,选择安装类型:
    • Developer Default:会安装 Server、Workbench、Shell 等,适合开发。
    • 若只需数据库服务,可选 Server only
  3. 按向导执行 Execute 安装所选组件。
  4. Type and Networking 中保持默认端口 3306(除非端口冲突,需与 Prisma 的 DATABASE_URL 一致)。
  5. Authentication 中保持默认 Strong Password(推荐)。
  6. 设置 root 用户密码并牢记;后续 Workbench、Prisma 连接都会用到。
  7. 将 MySQL 配置为 Windows Service,并勾选 Start the MySQL Server at System Startup(开机自启,便于开发)。
  8. 完成安装。

4.3 安装后如何确认 MySQL 已运行

  • 服务:按 Win + R,输入 services.msc,查找 MySQLMySQL80 等服务名,状态应为 正在运行
  • 命令行(若安装时勾选了命令行客户端且已加入 PATH):
mysql --version
  • 若无法直接执行 mysql,可在「开始菜单」打开 MySQL Command Line Client,用 root 密码登录测试。

4.4 安装 MySQL Workbench

  • 若已通过 MySQL Installer 勾选 Workbench,可跳过单独安装。
  • 否则打开 Workbench 下载页,选择 Windows / macOS / Linux 对应安装包,按向导安装即可。

4.5 使用 MySQL Workbench 连接数据库

  1. 打开 MySQL Workbench
  2. 主界面 MySQL Connections 区域点击 「+」MySQL Connections 旁的加号,新建连接。
  3. 填写连接参数(本地开发常见值):
字段 典型值 说明
Connection Name 任意名称,如 Local Dev 仅显示用
Hostname 127.0.0.1localhost 本机数据库
Port 3306 与安装时一致
Username root 或你创建的其他用户
Password 点击 Store in Keychain / Vault… 保存密码,避免每次输入
  1. 点击 Test Connection,提示成功即可 OK 保存。
  2. 双击该连接进入主工作区:左侧为 Navigator(库表列表),中间为 SQL 编辑与结果区

image.png

4.6 MySQL Workbench 基本操作

操作 步骤
创建数据库(Schema) 左侧 Schemas 面板空白处右键 → Create Schema… → 输入库名(如 mydb)→ Apply;或在 Query 窗口执行:CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
执行 SQL 顶部 File → New Query Tab 或工具栏新建查询标签 → 输入 SQL → 选中语句 → 点击 闪电图标 执行(或 Ctrl + Enter)。
查看表数据 Schemas 中展开数据库 → Tables → 表名右键 → Select Rows - Limit 1000
查看表结构 表名右键 → Table InspectorAlter Table
新建用户/授权(进阶) 菜单 Server → Users and Privileges(需相应权限);开发阶段常用 root,生产环境应使用最小权限账号。

Prisma 配合时:先在 Workbench 里 创建空库(如 mydb),再在项目 .env 中把 DATABASE_URL 指向该库;表结构通常交给 prisma migrate 管理,无需在 Workbench 里手工建每张表(除非你不用迁移、纯手写 SQL)。

五、接入 Prisma 与 MySQL

5.1 安装

npm install prisma @prisma/client
npx prisma init

prisma init 会创建 prisma/schema.prisma,并在项目根目录提示创建 .env

5.2 配置连接串(.env

DATABASE_URL="mysql://用户名:密码@主机:3306/数据库名"

示例(本地 MySQL):

DATABASE_URL="mysql://root:secret@localhost:3306/mydb"

注意:将 .env 加入 .gitignore,仓库中只保留 .env.example(不含真实密码)。

5.3 schema.prisma 示例

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  posts     Post[]
  createdAt DateTime @default(now())
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  published Boolean  @default(false)
  authorId  Int
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())
}

5.4 生成 Client

修改 schema 后执行:

npx prisma generate

会依据当前 schema.prisma 生成 node_modules/.prisma/client 等,供 TypeScript 使用。


六、Migrations(数据库迁移)详解

Migration 指:把「数据库结构应该怎样」从 Prisma Schema 出发,变成 可重复执行、可版本控制 的 SQL 变更步骤。团队协作时,所有人用同一套迁移文件,就能把本地/测试/生产数据库结构对齐。

6.1 为什么需要迁移

  • 可回顾:每次结构变更都有对应 SQL 文件与命名(如 20240323120000_init)。
  • 可复现:新同事或新环境执行同一套迁移即可得到一致表结构。
  • 与「只改库不改文件」对比:手动在 Navicat 里改表,无法自动同步到其他人的机器,也容易与 schema.prisma 不一致。

6.2 开发环境常用命令:prisma migrate dev

首次或每次修改 schema.prisma 后:

npx prisma migrate dev --name 描述本次变更的英文短语

例如:

npx prisma migrate dev --name init
npx prisma migrate dev --name add_post_published_index

该命令会:

  1. 对比当前 schema 与数据库状态,生成新的 SQL 迁移文件到 prisma/migrations/<时间戳>_<name>/migration.sql
  2. 应用这些迁移到当前 DATABASE_URL 指向的数据库
  3. 触发 prisma generate,更新 Prisma Client。

适合:本地开发共享开发数据库(团队约定好同一条 DATABASE_URL 时要注意冲突)。

6.3 生产 / CI:prisma migrate deploy

生产构建流程或服务器上,不要migrate dev(它会交互式、且偏向开发工作流)。应使用:

npx prisma migrate deploy

作用:只执行 prisma/migrations尚未应用的迁移,根据当前数据库反向改 schema,适合流水线与只读权限受限的环境。

典型顺序:

  1. 构建应用:npm run build
  2. 部署前或启动前:npx prisma migrate deploy
  3. 启动:npm run start

6.4 db pushmigrate 的区别

命令 适用场景
prisma migrate dev 有迁移历史、团队需要版本化 SQL;推荐正式项目
prisma db push 快速把 schema 推到数据库,生成迁移文件;原型、个人玩具项目或明确不保留迁移历史时可用

生产环境应依赖 migrate deploy + 已提交的 migrations 文件夹,而不是 db push

6.5 prisma/migrations 目录里有什么

prisma/migrations/
├── migration_lock.toml       # 锁定数据库提供方(如 mysql)
└── 20240323120000_init/
    └── migration.sql         # 本次迁移的 SQL(由 Prisma 生成)
  • migration.sql:可阅读、可审计;一般不要手改(除非你很清楚后果)。
  • 新增迁移 = 新文件夹 + 新 SQL,按时间顺序应用。

6.6 常见注意点

  • 修改已部署过的迁移文件可能导致校验失败;已上线的变更应通过新迁移追加修改。
  • 备份:生产执行 migrate deploy 前应有数据库备份策略。
  • Serverless(如部分 Vercel 函数)需注意 MySQL 连接数;必要时使用连接池或官方推荐的托管方案。

七、Prisma Client 单例(避免开发环境连接耗尽)

lib/prisma.ts

import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
  });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

八、在 Server Component 中查询

app/users/page.tsx

import { prisma } from "@/lib/prisma";

export default async function UsersPage() {
  const users = await prisma.user.findMany({
    orderBy: { createdAt: "desc" },
    include: { posts: true },
  });

  return (
    <main>
      <h1>用户列表</h1>
      <ul>
        {users.map((u) => (
          <li key={u.id}>
            {u.name ?? u.email} — 文章数:{u.posts.length}
          </li>
        ))}
      </ul>
    </main>
  );
}

九、Route Handler 示例

app/api/users/route.ts

import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

export async function GET() {
  const users = await prisma.user.findMany();
  return NextResponse.json(users);
}

export async function POST(request: Request) {
  const body = await request.json();
  const { email, name } = body as { email: string; name?: string };

  const user = await prisma.user.create({
    data: { email, name },
  });

  return NextResponse.json(user, { status: 201 });
}

十、数据的修改与删除接口(Route Handlers)

单条资源的 更新删除 通常不放在集合路径 /api/users 上,而是使用 动态路由 /api/users/[id]:路径里的 id 对应数据库主键(或业务唯一标识),与 REST 习惯一致(对「某一个用户」做 PATCH/DELETE)。

10.1 路由文件位置

在 App Router 中新建:

app/api/users/[id]/route.ts

同一文件内可导出 PATCH(部分更新)、PUT(全量更新,可选)、DELETE(删除)。Next.js 按 HTTP 方法分发到对应导出函数。

10.2 PATCH:部分更新(常用)

客户端只传需要改的字段(例如只改 name),服务端用 prisma.user.update 合并进数据库。

要点

  • where:用 id 定位一行;若不存在,Prisma 抛出 P2025(Record not found),应转为 404
  • data:只写入请求体里出现的字段,避免把未传字段覆盖成 null(需自行从 body 解构并组装 data)。

app/api/users/[id]/route.ts 示例(Next.js 15+ 中 paramsPromise,需 await):

import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";

type Ctx = { params: Promise<{ id: string }> };

export async function PATCH(request: Request, ctx: Ctx) {
  const { id } = await ctx.params;
  const userId = Number(id);
  if (Number.isNaN(userId)) {
    return NextResponse.json({ error: "Invalid id" }, { status: 400 });
  }

  const body = await request.json();
  const { name, email } = body as { name?: string; email?: string };

  const data: { name?: string; email?: string } = {};
  if (name !== undefined) data.name = name;
  if (email !== undefined) data.email = email;
  if (Object.keys(data).length === 0) {
    return NextResponse.json({ error: "No fields to update" }, { status: 400 });
  }

  try {
    const user = await prisma.user.update({
      where: { id: userId },
      data,
    });
    return NextResponse.json(user);
  } catch (e) {
    if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") {
      return NextResponse.json({ error: "User not found" }, { status: 404 });
    }
    throw e;
  }
}

调用示例(前端或其他客户端):

PATCH /api/users/1 HTTP/1.1
Content-Type: application/json

{"name": "新名字"}

10.3 PUT:全量更新(可选)

语义上 PUT 常表示「用请求体替换整个资源」。若 User 只有 emailname 等少量字段,可以在服务端规定:必须带上全部可写字段,否则 400;再执行 update

若业务上更关心「只改部分字段」,优先用 PATCH,避免客户端漏传字段导致误清空。

10.4 DELETE:删除一条记录

使用 prisma.user.delete,成功时返回 204 No Content(无响应体)较常见;也可返回 200 并带上已删除对象的 JSON,按团队约定即可。

import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";

type Ctx = { params: Promise<{ id: string }> };

export async function DELETE(_request: Request, ctx: Ctx) {
  const { id } = await ctx.params;
  const userId = Number(id);
  if (Number.isNaN(userId)) {
    return NextResponse.json({ error: "Invalid id" }, { status: 400 });
  }

  try {
    await prisma.user.delete({
      where: { id: userId },
    });
    return new NextResponse(null, { status: 204 });
  } catch (e) {
    if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") {
      return NextResponse.json({ error: "User not found" }, { status: 404 });
    }
    throw e;
  }
}

说明:若表上有 外键约束(例如 Post 依赖 User),删除用户可能触发 Prisma/MySQL 外键错误(如 P2003),需在业务层先删子表数据、或改用数据库 ON DELETE CASCADE、或禁止删除,并对该错误返回 409 等状态码。

10.5 按条件批量更新 / 删除(updateMany / deleteMany)

不通过 id 单条操作,而是按条件批量处理时使用:

// 将所有未发布文章标为已发布(示例)
await prisma.post.updateMany({
  where: { published: false },
  data: { published: true },
});

// 删除某邮箱前缀的测试用户(慎用,生产需强校验)
await prisma.user.deleteMany({
  where: { email: { startsWith: "test_" } },
});

要点updateMany / deleteMany 不会在 0 行匹配时抛错;若接口需要「至少删一行」再返回 404,需在执行后根据 count 自行判断。

10.6 Next.js 14 与 params 类型说明

若项目仍为 Next.js 14,部分版本中 params同步对象,签名可写为 { params: { id: string } }不使用 await params。升级到 Next.js 15 后请改为 params: Promise<...>await params,与官方 Route Handlers 类型一致。

10.7 前端请求代码(修改与删除)

以下使用浏览器原生 fetch,适用于 客户端组件(文件顶部需加 "use client")。请求路径与上文 Route Handler 一致:/api/users/[id]

要点

  • PATCH:设置 Content-Type: application/jsonbodyJSON.stringify 后的对象。
  • DELETE:若服务端返回 204 No Content没有响应体,不要调用 response.json(),根据 response.okresponse.status === 204 判断成功。
  • 失败时(4xx/5xx)若接口返回 JSON 错误信息,可 await response.json() 读取(注意先判断 Content-Typetry/catch)。

封装函数(可放在 lib/user-api.ts 或组件同文件内)

/** 部分更新用户(对应 PATCH /api/users/:id) */
export async function updateUser(
  id: number,
  data: { name?: string; email?: string }
): Promise<{ id: number; name: string | null; email: string }> {
  const res = await fetch(`/api/users/${id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  });

  const payload = await res.json().catch(() => ({}));

  if (!res.ok) {
    const message =
      typeof payload === "object" && payload !== null && "error" in payload
        ? String((payload as { error: unknown }).error)
        : res.statusText;
    throw new Error(message || `HTTP ${res.status}`);
  }

  return payload as { id: number; name: string | null; email: string };
}

/** 删除用户(对应 DELETE /api/users/:id,成功为 204 无 body) */
export async function deleteUser(id: number): Promise<void> {
  const res = await fetch(`/api/users/${id}`, {
    method: "DELETE",
  });

  if (res.ok) {
    return;
  }

  let message = res.statusText;
  try {
    const payload = (await res.json()) as { error?: string };
    if (payload.error) message = payload.error;
  } catch {
    /* 无 JSON 时忽略 */
  }
  throw new Error(message || `HTTP ${res.status}`);
}

在客户端组件中调用(示例)

"use client";

import { useState } from "react";
import { deleteUser, updateUser } from "@/lib/user-api";

export function UserActions({ userId }: { userId: number }) {
  const [loading, setLoading] = useState<"patch" | "delete" | null>(null);
  const [error, setError] = useState<string | null>(null);

  async function handleRename() {
    setError(null);
    setLoading("patch");
    try {
      const user = await updateUser(userId, { name: "新名字" });
      console.log("更新成功", user);
      // 例如:router.refresh() 或更新本地列表状态
    } catch (e) {
      setError(e instanceof Error ? e.message : "更新失败");
    } finally {
      setLoading(null);
    }
  }

  async function handleDelete() {
    if (!confirm("确定删除该用户?")) return;
    setError(null);
    setLoading("delete");
    try {
      await deleteUser(userId);
      console.log("删除成功");
      // 例如:跳转列表页或从状态中移除
    } catch (e) {
      setError(e instanceof Error ? e.message : "删除失败");
    } finally {
      setLoading(null);
    }
  }

  return (
    <div>
      {error && <p role="alert">{error}</p>}
      <button type="button" onClick={handleRename} disabled={loading !== null}>
        {loading === "patch" ? "更新中…" : "修改名字"}
      </button>
      <button type="button" onClick={handleDelete} disabled={loading !== null}>
        {loading === "delete" ? "删除中…" : "删除"}
      </button>
    </div>
  );
}

说明:若列表数据来自 Server Component,删除/更新成功后可在子组件中调用 import { useRouter } from "next/navigation"router.refresh(),让服务端重新渲染最新数据;或使用全局状态 / React Query 等自行同步列表。


十一、事务示例

import { prisma } from "@/lib/prisma";

export async function createUserWithPost(email: string, postTitle: string) {
  return prisma.$transaction(async (tx) => {
    const user = await tx.user.create({
      data: { email },
    });
    await tx.post.create({
      data: {
        title: postTitle,
        authorId: user.id,
      },
    });
    return user;
  });
}

十二、小结

  • 从官网安装 MySQL ServerMySQL Workbench,用 Workbench 测试连接建库执行 SQL 与查看表数据;下载页MySQL Community ServerMySQL Installer(Windows)MySQL Workbench
  • create-next-app 初始化项目,App Router 下页面在 app/,API 在 app/api/.../route.ts
  • Prismaschema.prisma 定义模型;migrate dev 在开发中生成并应用迁移;migrate deploy 在生产/CI 应用已提交的迁移。
  • MySQL 通过 DATABASE_URL 连接;全栈链路为:Next(服务端)→ Prisma Client → MySQL。
  • 单条资源的修改与删除使用 app/api/.../[id]/route.ts,导出 PATCH / DELETE(可选 PUT);不存在时处理 Prisma P2025 返回 404;批量操作用 updateMany / deleteMany。前端在客户端组件中用 fetchPATCH(JSON body)与 DELETE204 无响应体时不要 response.json()
  • prisma 官网:www.prisma.io/docs
  • next 官网 nextjs.frontendx.cn/

React.cache:让你的服务器组件告别“重复劳动”

作者 helloweilei
2026年3月23日 16:17

一句话总结:它是专为 React 服务器组件(RSC)设计的“记忆化”利器,能让你在同一个请求中,多次调用同一个函数,却只执行一次真实逻辑。

想象一下这个场景:你点了三份同样的外卖(同一篇博客文章的数据),商家会让三个厨师各做一遍,还是让一个厨师做一份然后分装三份?当然是后者!React.cache 就是那个帮你“只做一份”的调度员。


一、🤔 为什么需要 React.cache?从“外卖比喻”说起

在 Next.js 的 App Router 中,一个页面通常由多个组件组成:布局、页面本身、generateMetadata……每个组件都可能需要同样的数据。

没有缓存时

// 这三个地方都要用到文章数据,每次都重新查数据库!😱
// app/article/layout.tsx
const { title } = await getArticle()  // 查第一次

// app/article/page.tsx
const { title } = await getArticle()  // 查第二次

// app/article/layout.tsx 的 generateMetadata
const { title } = await getArticle()  // 查第三次

结果是:三次数据库查询,三次网络请求,三次等待时间。 这在复杂页面里就是性能灾难。

有了 React.cache 之后

// utils.ts
import { cache } from 'react'

export const getArticle = cache(async (id) => {
  return await db.article.findUnique({ where: { id } })
})

无论你在多少个组件里调用 getArticle(id),只要 id 相同,真正的数据库查询只执行一次,后续调用直接返回缓存结果。

二、📦 核心 API:超级简单

React.cache 的 API 极其简洁,长这样:

import { cache } from 'react'

const cachedFn = cache(originalFn)
  • 入参:一个函数(可以是同步或异步的)
  • 返回值:一个具有相同签名的“记忆化”版本
  • 缓存依据:函数调用的所有参数(严格相等比较 Object.is

看个实际例子:

import { cache } from 'react'

const fetchWeather = async (city) => {
  console.log(`🌤️ 真实请求: ${city}`)
  const res = await fetch(`https://api.weather.com/${city}`)
  return res.json()
}

const getCachedWeather = cache(fetchWeather)

// 第一次调用:执行真实请求
const nyc1 = await getCachedWeather('New York')  // 日志输出

// 第二次调用(相同参数):直接返回缓存,不执行函数
const nyc2 = await getCachedWeather('New York')  // 无日志

// 不同参数:重新执行
const london = await getCachedWeather('London')   // 日志输出

注意:缓存不仅存成功结果,也缓存错误。如果第一次调用抛异常,后续相同参数的调用也会抛出同样的异常。

三、🎯 实战场景一:共享数据快照

这是 RSC 中最经典的使用场景:多个组件需要同一份数据

在掘金的一篇实战文章中,作者展示了这样一个结构:

// app/article/utils.ts
import { cache } from 'react'
import { db } from '@/lib/db'

export const getArticle = cache(async (id: string) => {
  // 模拟耗时的数据库查询
  await new Promise(r => setTimeout(r, 2000))
  return await db.article.findUnique({ where: { id } })
})

然后在布局和页面中同时使用:

// app/article/[id]/layout.tsx
import { getArticle } from './utils'

export default async function Layout({ params: { id } }) {
  const { title } = await getArticle(id)  // ← 调用1
  return (
    <div className="banner">
      您正在阅读:{title}
      {children}
    </div>
  )
}

// app/article/[id]/page.tsx
import { getArticle } from './utils'

export default async function Page({ params: { id } }) {
  const { title } = await getArticle(id)  // ← 调用2,走缓存
  return <h1>{title}</h1>
}

效果:两个组件虽然都调用了 getArticle,但数据库只查询了一次。而且因为用的是同一个记忆化函数,它们拿到的数据快照完全一致——不会出现布局显示“标题A”,页面显示“标题B”的乌龙。

四、⚡ 实战场景二:预加载数据(Preload Pattern)

这是官方文档特别推荐的一个高阶技巧:在组件真正需要数据之前,提前发起请求,利用缓存把数据“预热”

import { cache } from 'react'
import { db } from '@/lib/db'

const getUser = cache(async (id: string) => {
  return await db.user.findUnique({ where: { id } })
})

// 预加载函数:只调用,不 await
function preloadUser(id: string) {
  void getUser(id)  // 启动数据获取,但不等待
}

// 实际使用的组件
async function UserProfile({ id }: { id: string }) {
  const user = await getUser(id)  // 如果预加载已完成,这里几乎瞬间返回
  return <div>{user.name}</div>
}

// 页面组件
export default async function Page({ params }: { params: { id: string } }) {
  preloadUser(params.id)  // ← 立即开始获取用户数据
  // ... 其他计算工作 ...
  return <UserProfile id={params.id} />  // ← 用到时可能已经在缓存里了
}

这个模式的好处是并行化:在等待预加载数据的同时,React 可以继续执行其他计算或渲染其他组件,而不是串行等待。

五、⚠️ 三个你必须知道的陷阱

陷阱1:React.cache 只适用于服务器组件

这是官方文档明确强调的:cache 仅供与 React 服务器组件一起使用。在客户端组件('use client')里用,不会报错,但缓存不生效

为什么?因为缓存的访问是通过 React 内部的**请求上下文(request storage)**实现的,而这个上下文只在服务器端渲染时才存在。

社区里已经有人在 Next.js Discord 里问过这个问题:想把 React.cache 用在客户端组件里做请求去重,结果是——不管用。建议用 React Query、SWR 等专门的客户端缓存方案。

陷阱2:不同记忆化函数,不共享缓存

这是最容易踩的坑!看这段错误代码:

// ❌ 错误示例
function ComponentA() {
  const getData = cache(fetchData)  // 每次渲染创建新的缓存函数
  const data = getData(id)
}

function ComponentB() {
  const getData = cache(fetchData)  // 又一个独立缓存函数
  const data = getData(id)
}

两个组件各创自己的记忆化函数,缓存互相隔离,重复劳动依然发生。

正确做法:把 cache 调用放在模块顶层,导出一个共享的缓存函数:

// ✅ 正确:导出同一个缓存函数
// utils/data.ts
import { cache } from 'react'

export const getData = cache(fetchData)

// 组件A和B都 import 同一个 getData

陷阱3:在组件外部调用,不触发缓存

import { cache } from 'react'

const getUser = cache(async (id) => {
  return await db.user.findUnique({ where: { id } })
})

// ❌ 在组件外部调用,不会写入缓存
await getUser('123')

export default async function Page() {
  // ✅ 在组件内部调用,会使用缓存
  const user = await getUser('123')
}

React 只在组件渲染期间提供缓存上下文,外部调用虽然能执行函数,但缓存不会被读取或写入。

六、📊 cache vs useMemo vs memo:一张图看懂

很多初学者会混淆这几种“记忆化”机制,官方文档给了很清晰的区分:

API 适用场景 缓存范围 缓存依据 生命周期
React.cache 服务器组件 跨组件共享 函数参数 单次请求
useMemo 客户端组件 单个组件实例 依赖数组 组件生命周期
React.memo 客户端组件 组件渲染结果 Props 浅比较 组件生命周期

简单理解:

  • React.cache“一个请求内,所有人共用一份”(餐厅版:一个厨师做一份菜,分给三桌客人)
  • useMemo“一个组件内,重复渲染不重算”(餐厅版:同一桌客人反复点同一道菜,后厨不重做)
  • React.memo“Props 没变就不重渲染”(餐厅版:客人没换菜,服务员就不重新下单)

七、🔗 与 Next.js 的深度集成

在 Next.js 中,React.cache官方推荐的数据库查询缓存方案。而且 Next.js 还在持续优化它们的配合:

  • 自动去重:最新版本的 Next.js 修复了在 "use cache" 函数中使用 React.cache 时的去重问题
  • 静态渲染 + 缓存:当页面使用 export const revalidate = 3600 时,React.cache 会在每次重新渲染时清空缓存,配合 revalidate 实现定时更新

GitHub Trending 上的热门 UI 项目也大量使用 React.cache 来缓存组件文档和代码高亮的结果,提升首屏加载速度达 75%

八、🎯 什么时候用 React.cache?

✅ 推荐场景 ❌ 不推荐场景
在 RSC 中多次调用同一个数据获取函数 在客户端组件中
布局 + 页面 + 元数据都需要同一份数据 数据只在单个组件中使用一次
预加载数据,提升性能 需要跨请求持久化缓存(用 Redis/CDN)
数据库查询、API 调用、复杂计算 数据在客户端频繁变化

九、💡 总结:一张“外卖调度单”

回到开头的比喻。React.cache 就像餐厅里的智能调度系统

  • 它记住了“谁点了什么菜”
  • 发现重复订单时,直接复用已有的备菜
  • 所有菜品在同一批次送达,保证口味一致

核心记忆点

  1. 适用场景:仅限 React 服务器组件(RSC)
  2. 缓存依据:函数的所有参数(严格相等)
  3. 共享规则:必须使用同一个记忆化函数实例
  4. 生命周期:每次请求独立,请求结束缓存清空

Cesium中的坐标系及其转换

作者 MrGud
2026年3月23日 16:05

Cesium中的坐标系及其转换

引言

Cesium作为一款强大的三维地球和地图可视化引擎,其核心魅力在于能够在虚拟空间中精准地表达地理信息。而这一切的基础,正是Cesium中精心设计的坐标系系统。无论是放置一个标记、绘制一条航线,还是模拟卫星运动,都离不开对坐标系的理解和运用。本文将系统介绍Cesium中的主要坐标系,并详细说明它们之间的转换方法,帮助你在开发中游刃有余地处理各种空间数据。

一、Cesium中的主要坐标系

Cesium涉及四类核心坐标系,它们分别服务于不同的应用场景。

1.1 屏幕坐标(Cartesian2)

屏幕坐标是二维笛卡尔坐标系,以像素为单位描述屏幕上的位置。其原点位于屏幕(Canvas)的左上角,水平向右为X轴正方向,垂直向下为Y轴正方向。

// 创建一个屏幕坐标点 (x=100, y=200)
const screenPosition = new Cesium.Cartesian2(100, 200);

屏幕坐标常用于处理鼠标交互,例如获取鼠标点击位置的像素坐标。

1.2 笛卡尔空间直角坐标(Cartesian3)

笛卡尔空间直角坐标,又称世界坐标,是Cesium中最核心的坐标系。它以地球几何中心为原点,使用作为单位,通过Cesium.Cartesian3(x, y, z)表示。

在这个坐标系中:

  • X轴:指向本初子午线与赤道的交点
  • Y轴:指向东经90度经线与赤道的交点
  • Z轴:指向北极
// 创建一个笛卡尔坐标点
const worldPosition = new Cesium.Cartesian3(1215000.0, -4736000.0, 4081000.0);

重要提示:Cesium平台内所有用到坐标的地方,核心都是Cartesian3对象。无论是实体位置、相机位置,还是模型变换,最终都会转换为Cartesian3进行计算。

1.3 地理坐标(Cartographic)

地理坐标是更符合人类认知的坐标系,它基于WGS84椭球体模型,使用经度、纬度、高度来描述位置。但在Cesium内部,地理坐标的经度和纬度是以弧度而非角度存储的。

// 创建地理坐标(弧度制)
const cartographic = new Cesium.Cartographic(
    Cesium.Math.toRadians(116.3975), // 经度:116.3975度转弧度
    Cesium.Math.toRadians(39.9075),  // 纬度:39.9075度转弧度
    50.0                              // 高度:50米
);

由于弧度值对人不直观,我们通常通过工具函数进行度和弧度的转换:

// 度转弧度
const radians = Cesium.Math.toRadians(degrees);
// 弧度转度
const degrees = Cesium.Math.toDegrees(radians);

1.4 参考框架:地固系与地惯系

除了上述基础坐标系,Cesium还提供了两个重要的参考框架概念,用于处理动态的空间关系。

地固系(FIXED / ECEF)

地固系(Earth-Centered, Earth-Fixed)是Cesium的默认参考框架。在这个框架中,地球本身是静止的,星空围绕地球旋转。它适用于大多数需要将物体固定在地球表面上的场景,如标记城市、绘制道路等。

地惯系(INERTIAL / ICRF)

地惯系(Earth-Centered Inertial),在Cesium中称为ICRF(国际天文参考坐标系)。在这个框架中,星空是静止的,地球自身在旋转。它对于需要精确模拟天体运动和卫星轨道的场景至关重要。

// 在CZML中指定使用惯性参考系
{
    id: "satellite",
    position: {
        referenceFrame: "INERTIAL", // 设置为惯性系
        cartesian: [x, y, z]        // 惯性系下的坐标
    }
}

二、坐标系之间的转换

理解了各类坐标系后,掌握它们之间的转换方法同样重要。Cesium提供了丰富的API来完成这些转换。

2.1 WGS84地理坐标 ↔ 笛卡尔坐标

这是开发中最常用的转换。Cesium提供了便捷的静态方法,可以直接将经纬度(度)转换为笛卡尔坐标。

// 经纬度(度)→ 笛卡尔坐标
const position = Cesium.Cartesian3.fromDegrees(
    116.3975,  // 经度
    39.9075,   // 纬度
    50.0       // 高度(米)
);

// 批量转换多个点(不带高度)
const positions = Cesium.Cartesian3.fromDegreesArray([
    116.3975, 39.9075,
    121.4737, 31.2304
]);

// 批量转换多个点(带高度)
const positionsWithHeight = Cesium.Cartesian3.fromDegreesArrayHeights([
    116.3975, 39.9075, 50.0,
    121.4737, 31.2304, 20.0
]);

逆向转换(笛卡尔 → 地理坐标)同样简单:

// 笛卡尔坐标 → 地理坐标(弧度制)
const cartographic = Cesium.Cartographic.fromCartesian(cartesian3);

// 或使用椭球体方法
const cartographic2 = Cesium.Ellipsoid.WGS84.cartesianToCartographic(cartesian3);

// 弧度制转角度制
const longitude = Cesium.Math.toDegrees(cartographic.longitude);
const latitude = Cesium.Math.toDegrees(cartographic.latitude);
const height = cartographic.height;

2.2 屏幕坐标 ↔ 笛卡尔坐标

屏幕坐标与笛卡尔坐标的转换常用于实现鼠标拾取功能。

屏幕坐标 → 笛卡尔坐标有三种层次:

// 1. 获取包含地形、模型、倾斜摄影的坐标
const pickPosition = viewer.scene.pickPosition(screenCoord);

// 2. 获取地球表面坐标(包含地形,不包含模型)
const globePosition = viewer.scene.globe.pick(
    viewer.camera.getPickRay(screenCoord), 
    viewer.scene
);

// 3. 获取参考椭球面坐标(不包含地形和模型)
const ellipsoidPosition = viewer.scene.camera.pickEllipsoid(screenCoord);

笛卡尔坐标 → 屏幕坐标使用SceneTransforms工具:

// 将世界坐标转换为窗口坐标(像素位置)
const windowCoord = Cesium.SceneTransforms.wgs84ToWindowCoordinates(
    viewer.scene, 
    worldPosition
);

// 如果需要考虑高分屏适配,可使用wgs84ToDrawingBufferCoordinates
const drawingBufferCoord = Cesium.SceneTransforms.wgs84ToDrawingBufferCoordinates(
    viewer.scene, 
    worldPosition
);

2.3 空间位置变换

坐标转换的最终目的往往是为了进行空间变换。Cesium提供了强大的数学工具支持:

工具类 用途
Cesium.Cartesian3 表示三维点、向量
Cesium.Matrix3 3x3矩阵,描述旋转变换
Cesium.Matrix4 4x4矩阵,描述旋转+平移变换
Cesium.Quaternion 四元数,描述绕任意轴的旋转
Cesium.Transforms 参考系转换工具

重要原则:所有空间变换(平移、旋转、缩放)都需要在笛卡尔坐标系(Cartesian3)中进行。

三、参考框架转换实战

3.1 将相机固定在惯性系

默认情况下Cesium使用地固系(地球静止)。如果要观察卫星在惯性系中的运动,需要将相机固定在惯性系中,让地球旋转起来:

// 每一帧更新时,将相机固定在ICRF惯性系(需要开启动画(shouldAnimate)及加快倍速更明显)
function icrf(scene, time) {
    if (scene.mode !== Cesium.SceneMode.SCENE3D) {
        return;
    }

    // 计算ICRF到地固系的旋转矩阵
    const icrfToFixed = Cesium.Transforms.computeIcrfToFixedMatrix(time);
    
    if (Cesium.defined(icrfToFixed)) {
        const camera = viewer.camera;
        const offset = Cesium.Cartesian3.clone(camera.position);
        const transform = Cesium.Matrix4.fromRotationTranslation(icrfToFixed);
        camera.lookAtTransform(transform, offset);
    }
}

// 添加帧更新监听
viewer.scene.postUpdate.addEventListener(icrf);

这段代码的效果是:星空和卫星轨道保持静止,而地球在画面中缓慢旋转

3.2 在惯性系中计算月球位置

对于需要精确模拟天体运动的场景,可以使用Simon1994PlanetaryPositions计算月球在惯性系中的位置:

const time = Cesium.JulianDate.now();
const moonPosition = new Cesium.Cartesian3();

Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(
    time, 
    moonPosition
);

console.log('月球在惯性系中的位置:', moonPosition);

总结

Cesium的坐标系系统可以归纳为三个层次:

坐标系类型 表示方式 单位 应用场景
屏幕坐标 Cartesian2 像素 鼠标交互、UI定位
笛卡尔坐标 Cartesian3 空间计算、模型变换
地理坐标 Cartographic 弧度/米 数据输入输出
参考框架 FIXED/INERTIAL - 动态场景模拟

掌握这些坐标系及其转换方法,是高效开发Cesium应用的基础。无论是简单的地理标记,还是复杂的卫星轨道模拟,你都能找到合适的工具和API来精确表达你的空间数据。

@tencent-weixin/openclaw-weixin 插件深度解析(四):API 协议与数据流设计

作者 毛骗导演
2026年3月22日 13:52

RESTful API、类型系统、同步缓冲区

API 协议是插件与微信服务器通信的基础,而数据流设计决定了消息如何在整个系统中流转。本文将深入剖析 OpenClaw WeChat 插件的 API 协议设计、数据类型系统、同步缓冲区机制以及日志与监控体系,帮助开发者理解其底层通信原理。

一、API 协议架构概览

OpenClaw WeChat 插件采用 RESTful API 与微信服务器通信,所有请求使用 JSON 格式,通过 HTTP/HTTPS 传输:

┌─────────────────────────────────────────────────────────────────────────┐
│                         API Protocol Stack                               │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │                        Application Layer                         │   │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐  ┌─────────┐ │   │
│  │  │ getUpdates  │  │ sendMessage │  │ getUploadUrl│  │getConfig│ │   │
│  │  └─────────────┘  └─────────────┘  └─────────────┘  └─────────┘ │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                          │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │                      Transport Layer (HTTP)                      │   │
│  │  POST /ilink/bot/getupdates          JSON Request/Response      │   │
│  │  POST /ilink/bot/sendmessage                                    │   │
│  │  POST /ilink/bot/getuploadurl                                   │   │
│  │  POST /ilink/bot/getconfig                                      │   │
│  │  POST /ilink/bot/sendtyping                                     │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                          │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │                      Security Layer                              │   │
│  │  Authorization: Bearer <token>                                   │   │
│  │  AuthorizationType: ilink_bot_token                              │   │
│  │  X-WECHAT-UIN: <random>                                          │   │
│  │  SKRouteTag: <optional>                                          │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

二、核心 API 接口详解

2.1 API 选项配置

所有 API 调用共享统一的选项配置:

export type WeixinApiOptions = {
  baseUrl: string;
  token?: string;
  timeoutMs?: number;
  /** Long-poll timeout for getUpdates (server may hold the request up to this). */
  longPollTimeoutMs?: number;
};

默认超时配置根据 API 类型区分:

const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
const DEFAULT_API_TIMEOUT_MS = 15_000;
const DEFAULT_CONFIG_TIMEOUT_MS = 10_000;

2.2 通用请求构建

所有 API 请求共享统一的请求构建逻辑:

async function apiFetch(params: {
  baseUrl: string;
  endpoint: string;
  body: string;
  token?: string;
  timeoutMs: number;
  label: string;
}): Promise<string> {
  const base = ensureTrailingSlash(params.baseUrl);
  const url = new URL(params.endpoint, base);
  const hdrs = buildHeaders({ token: params.token, body: params.body });
  logger.debug(`POST ${redactUrl(url.toString())} body=${redactBody(params.body)}`);

  const controller = new AbortController();
  const t = setTimeout(() => controller.abort(), params.timeoutMs);
  try {
    const res = await fetch(url.toString(), {
      method: "POST",
      headers: hdrs,
      body: params.body,
      signal: controller.signal,
    });
    clearTimeout(t);
    const rawText = await res.text();
    logger.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`);
    if (!res.ok) {
      throw new Error(`${params.label} ${res.status}: ${rawText}`);
    }
    return rawText;
  } catch (err) {
    clearTimeout(t);
    throw err;
  }
}

2.3 请求头构建

请求头包含身份验证和路由信息:

function buildHeaders(opts: { token?: string; body: string }): Record<string, string> {
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
    AuthorizationType: "ilink_bot_token",
    "Content-Length": String(Buffer.byteLength(opts.body, "utf-8")),
    "X-WECHAT-UIN": randomWechatUin(),
  };
  if (opts.token?.trim()) {
    headers.Authorization = `Bearer ${opts.token.trim()}`;
  }
  const routeTag = loadConfigRouteTag();
  if (routeTag) {
    headers.SKRouteTag = routeTag;
  }
  return headers;
}

/** X-WECHAT-UIN header: random uint32 -> decimal string -> base64. */
function randomWechatUin(): string {
  const uint32 = crypto.randomBytes(4).readUInt32BE(0);
  return Buffer.from(String(uint32), "utf-8").toString("base64");
}

2.4 GetUpdates 长轮询

GetUpdates 是消息接收的核心接口,采用长轮询机制:

export async function getUpdates(
  params: GetUpdatesReq & {
    baseUrl: string;
    token?: string;
    timeoutMs?: number;
  },
): Promise<GetUpdatesResp> {
  const timeout = params.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
  try {
    const rawText = await apiFetch({
      baseUrl: params.baseUrl,
      endpoint: "ilink/bot/getupdates",
      body: JSON.stringify({
        get_updates_buf: params.get_updates_buf ?? "",
        base_info: buildBaseInfo(),
      }),
      token: params.token,
      timeoutMs: timeout,
      label: "getUpdates",
    });
    const resp: GetUpdatesResp = JSON.parse(rawText);
    return resp;
  } catch (err) {
    // Long-poll timeout is normal; return empty response so caller can retry
    if (err instanceof Error && err.name === "AbortError") {
      logger.debug(`getUpdates: client-side timeout after ${timeout}ms, returning empty response`);
      return { ret: 0, msgs: [], get_updates_buf: params.get_updates_buf };
    }
    throw err;
  }
}

长轮询的特点:

  • 客户端设置 35 秒超时
  • 服务器保持连接直到有新消息或超时
  • 客户端超时视为正常情况,自动重试
  • 返回 get_updates_buf 用于下次请求

2.5 发送消息

SendMessage 用于向用户发送消息:

export async function sendMessage(
  params: WeixinApiOptions & { body: SendMessageReq },
): Promise<void> {
  await apiFetch({
    baseUrl: params.baseUrl,
    endpoint: "ilink/bot/sendmessage",
    body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
    token: params.token,
    timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
    label: "sendMessage",
  });
}

2.6 获取上传 URL

GetUploadUrl 用于获取 CDN 上传的预签名 URL:

export async function getUploadUrl(
  params: GetUploadUrlReq & WeixinApiOptions,
): Promise<GetUploadUrlResp> {
  const rawText = await apiFetch({
    baseUrl: params.baseUrl,
    endpoint: "ilink/bot/getuploadurl",
    body: JSON.stringify({
      filekey: params.filekey,
      media_type: params.media_type,
      to_user_id: params.to_user_id,
      rawsize: params.rawsize,
      rawfilemd5: params.rawfilemd5,
      filesize: params.filesize,
      no_need_thumb: params.no_need_thumb,
      aeskey: params.aeskey,
      base_info: buildBaseInfo(),
    }),
    token: params.token,
    timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
    label: "getUploadUrl",
  });
  const resp: GetUploadUrlResp = JSON.parse(rawText);
  return resp;
}

2.7 获取配置

GetConfig 用于获取用户的配置信息,包括 typing_ticket:

export async function getConfig(
  params: WeixinApiOptions & { ilinkUserId: string; contextToken?: string },
): Promise<GetConfigResp> {
  const rawText = await apiFetch({
    baseUrl: params.baseUrl,
    endpoint: "ilink/bot/getconfig",
    body: JSON.stringify({
      ilink_user_id: params.ilinkUserId,
      context_token: params.contextToken,
      base_info: buildBaseInfo(),
    }),
    token: params.token,
    timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
    label: "getConfig",
  });
  const resp: GetConfigResp = JSON.parse(rawText);
  return resp;
}

2.8 发送打字指示器

SendTyping 用于向用户显示"正在输入"状态:

export async function sendTyping(
  params: WeixinApiOptions & { body: SendTypingReq },
): Promise<void> {
  await apiFetch({
    baseUrl: params.baseUrl,
    endpoint: "ilink/bot/sendtyping",
    body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
    token: params.token,
    timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
    label: "sendTyping",
  });
}

三、数据类型系统

3.1 基础信息类型

每个 API 请求都包含基础信息:

export interface BaseInfo {
  channel_version?: string;
}

function readChannelVersion(): string {
  try {
    const dir = path.dirname(fileURLToPath(import.meta.url));
    const pkgPath = path.resolve(dir, "..", "..", "package.json");
    const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: string };
    return pkg.version ?? "unknown";
  } catch {
    return "unknown";
  }
}

const CHANNEL_VERSION = readChannelVersion();

export function buildBaseInfo(): BaseInfo {
  return { channel_version: CHANNEL_VERSION };
}

3.2 消息类型定义

消息系统支持多种类型的消息项:

export const MessageItemType = {
  NONE: 0,
  TEXT: 1,
  IMAGE: 2,
  VOICE: 3,
  FILE: 4,
  VIDEO: 5,
} as const;

export const MessageType = {
  NONE: 0,
  USER: 1,
  BOT: 2,
} as const;

export const MessageState = {
  NEW: 0,
  GENERATING: 1,
  FINISH: 2,
} as const;

3.3 消息项结构

消息项采用联合类型设计,通过 type 字段区分:

export interface MessageItem {
  type?: number;
  create_time_ms?: number;
  update_time_ms?: number;
  is_completed?: boolean;
  msg_id?: string;
  ref_msg?: RefMessage;
  text_item?: TextItem;
  image_item?: ImageItem;
  voice_item?: VoiceItem;
  file_item?: FileItem;
  video_item?: VideoItem;
}

export interface TextItem {
  text?: string;
}

export interface ImageItem {
  media?: CDNMedia;
  thumb_media?: CDNMedia;
  aeskey?: string;
  url?: string;
  mid_size?: number;
  thumb_size?: number;
  hd_size?: number;
}

export interface VoiceItem {
  media?: CDNMedia;
  encode_type?: number;
  sample_rate?: number;
  playtime?: number;
  text?: string;
}

export interface FileItem {
  media?: CDNMedia;
  file_name?: string;
  md5?: string;
  len?: string;
}

export interface VideoItem {
  media?: CDNMedia;
  video_size?: number;
  play_length?: number;
  thumb_media?: CDNMedia;
}

3.4 CDN 媒体引用

媒体文件通过 CDN 引用访问:

export interface CDNMedia {
  encrypt_query_param?: string;
  aes_key?: string;
  encrypt_type?: number;
}

3.5 统一消息结构

WeixinMessage 是统一的消息结构:

export interface WeixinMessage {
  seq?: number;
  message_id?: number;
  from_user_id?: string;
  to_user_id?: string;
  client_id?: string;
  create_time_ms?: number;
  update_time_ms?: number;
  delete_time_ms?: number;
  session_id?: string;
  group_id?: string;
  message_type?: number;
  message_state?: number;
  item_list?: MessageItem[];
  context_token?: string;
}

关键字段说明:

  • seq:消息序列号,用于排序
  • message_id:唯一消息标识
  • from_user_id / to_user_id:发送者和接收者
  • client_id:客户端生成的消息 ID
  • create_time_ms:消息创建时间(毫秒时间戳)
  • session_id:会话标识
  • item_list:消息内容项列表
  • context_token:上下文令牌,回复时必须携带

3.6 GetUpdates 请求/响应

export interface GetUpdatesReq {
  /** @deprecated compat only, will be removed */
  sync_buf?: string;
  /** Full context buf cached locally; send "" when none (first request or after reset). */
  get_updates_buf?: string;
}

export interface GetUpdatesResp {
  ret?: number;
  errcode?: number;
  errmsg?: string;
  msgs?: WeixinMessage[];
  get_updates_buf?: string;
  longpolling_timeout_ms?: number;
}

3.7 打字状态

export const TypingStatus = {
  TYPING: 1,
  CANCEL: 2,
} as const;

export interface SendTypingReq {
  ilink_user_id?: string;
  typing_ticket?: string;
  status?: number;
}

四、同步缓冲区机制

4.1 同步缓冲区的作用

同步缓冲区(sync buffer)是实现消息不丢失的关键机制:

┌─────────────────────────────────────────────────────────────────────────┐
│                      Sync Buffer Flow                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  Client                    Weixin Server                                 │
│    │                            │                                        │
│    │  1. getUpdates(buf="")    │                                        │
│    │ -------------------------> │                                        │
│    │                            │                                        │
│    │  2. msgs: [A, B, C]       │                                        │
│    │     new_buf: "XYZ123"     │                                        │
│    │ <------------------------- │                                        │
│    │                            │                                        │
│    │  [Save "XYZ123" to file]  │                                        │
│    │                            │                                        │
│    │  3. getUpdates(buf="XYZ123")                                       │
│    │ -------------------------> │                                        │
│    │                            │                                        │
│    │  [Server knows client has A, B, C]                                 │
│    │                            │                                        │
│    │  4. msgs: [D, E]          │                                        │
│    │     new_buf: "ABC789"     │                                        │
│    │ <------------------------- │                                        │
│    │                            │                                        │
│    │  [Save "ABC789" to file]  │                                        │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

4.2 同步缓冲区存储

export type SyncBufData = {
  get_updates_buf: string;
};

export function getSyncBufFilePath(accountId: string): string {
  return path.join(resolveAccountsDir(), `${accountId}.sync.json`);
}

export function saveGetUpdatesBuf(filePath: string, getUpdatesBuf: string): void {
  const dir = path.dirname(filePath);
  fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(filePath, JSON.stringify({ get_updates_buf: getUpdatesBuf }, null, 0), "utf-8");
}

4.3 多层兼容性回退

同步缓冲区加载支持多层回退:

export function loadGetUpdatesBuf(filePath: string): string | undefined {
  const value = readSyncBufFile(filePath);
  if (value !== undefined) return value;

  // Compat: if given path uses a normalized accountId (e.g. "b0f5860fdecb-im-bot.sync.json"),
  // also try the old raw-ID filename (e.g. "b0f5860fdecb@im.bot.sync.json").
  const accountId = path.basename(filePath, ".sync.json");
  const rawId = deriveRawAccountId(accountId);
  if (rawId) {
    const compatPath = path.join(resolveAccountsDir(), `${rawId}.sync.json`);
    const compatValue = readSyncBufFile(compatPath);
    if (compatValue !== undefined) return compatValue;
  }

  // Legacy fallback: old single-account installs stored syncbuf without accountId.
  return readSyncBufFile(getLegacySyncBufDefaultJsonPath());
}

回退层级:

  1. 主路径:规范化账号 ID 的同步文件
  2. 兼容路径:原始格式账号 ID 的同步文件
  3. 遗留路径:单账号时代的默认同步文件

五、状态目录管理

5.1 状态目录解析

插件使用统一的状态目录存储所有持久化数据:

export function resolveStateDir(): string {
  return (
    process.env.OPENCLAW_STATE_DIR?.trim() ||
    process.env.CLAWDBOT_STATE_DIR?.trim() ||
    path.join(os.homedir(), ".openclaw")
  );
}

环境变量优先级:

  1. OPENCLAW_STATE_DIR:首选环境变量
  2. CLAWDBOT_STATE_DIR:向后兼容的旧变量名
  3. 默认路径:~/.openclaw

5.2 目录结构

~/.openclaw/
├── openclaw-weixin/
│   ├── accounts.json              # 账号索引
│   ├── accounts/
│   │   ├── {accountId}.json       # 账号凭证
│   │   └── {accountId}.sync.json  # 同步缓冲区
│   └── debug-mode.json            # 调试模式状态
└── credentials/
    └── openclaw-weixin-{accountId}-allowFrom.json  # 授权列表

六、日志系统

6.1 日志架构

插件使用与 OpenClaw 核心统一的日志格式:

const MAIN_LOG_DIR = path.join("/tmp", "openclaw");
const SUBSYSTEM = "gateway/channels/openclaw-weixin";
const RUNTIME = "node";
const RUNTIME_VERSION = process.versions.node;
const HOSTNAME = os.hostname() || "unknown";

6.2 日志级别

const LEVEL_IDS: Record<string, number> = {
  TRACE: 1,
  DEBUG: 2,
  INFO: 3,
  WARN: 4,
  ERROR: 5,
  FATAL: 6,
};

const DEFAULT_LOG_LEVEL = "INFO";

6.3 日志记录实现

function writeLog(level: string, message: string, accountId?: string): void {
  const levelId = LEVEL_IDS[level] ?? LEVEL_IDS.INFO;
  if (levelId < minLevelId) return;

  const now = new Date();
  const loggerName = buildLoggerName(accountId);
  const prefixedMessage = accountId ? `[${accountId}] ${message}` : message;
  const entry = JSON.stringify({
    "0": loggerName,
    "1": prefixedMessage,
    _meta: {
      runtime: RUNTIME,
      runtimeVersion: RUNTIME_VERSION,
      hostname: HOSTNAME,
      name: loggerName,
      parentNames: PARENT_NAMES,
      date: now.toISOString(),
      logLevelId: LEVEL_IDS[level] ?? LEVEL_IDS.INFO,
      logLevelName: level,
    },
    time: toLocalISO(now),
  });

  try {
    if (!logDirEnsured) {
      fs.mkdirSync(MAIN_LOG_DIR, { recursive: true });
      logDirEnsured = true;
    }
    fs.appendFileSync(resolveMainLogPath(), `${entry}\n`, "utf-8");
  } catch {
    // Best-effort; never block on logging failures.
  }
}

6.4 日志格式

日志采用 JSON Lines 格式,便于结构化处理:

{
  "0": "gateway/channels/openclaw-weixin/b0f5860fdecb-im-bot",
  "1": "[b0f5860fdecb-im-bot] inbound message: from=xxx@im.wechat types=1",
  "_meta": {
    "runtime": "node",
    "runtimeVersion": "22.0.0",
    "hostname": "myhost",
    "name": "gateway/channels/openclaw-weixin/b0f5860fdecb-im-bot",
    "parentNames": ["openclaw"],
    "date": "2026-03-22T10:30:00.000Z",
    "logLevelId": 3,
    "logLevelName": "INFO"
  },
  "time": "2026-03-22T18:30:00.000+08:00"
}

6.5 子日志器

支持按账号创建子日志器:

export type Logger = {
  info(message: string): void;
  debug(message: string): void;
  warn(message: string): void;
  error(message: string): void;
  withAccount(accountId: string): Logger;
  getLogFilePath(): string;
  close(): void;
};

function createLogger(accountId?: string): Logger {
  return {
    info(message: string): void {
      writeLog("INFO", message, accountId);
    },
    // ... 其他级别
    withAccount(id: string): Logger {
      return createLogger(id);
    },
  };
}

七、敏感信息脱敏

7.1 脱敏工具函数

export function truncate(s: string | undefined, max: number): string {
  if (!s) return "";
  if (s.length <= max) return s;
  return `${s.slice(0, max)}…(len=${s.length})`;
}

export function redactToken(token: string | undefined, prefixLen = 6): string {
  if (!token) return "(none)";
  if (token.length <= prefixLen) return `****(len=${token.length})`;
  return `${token.slice(0, prefixLen)}…(len=${token.length})`;
}

export function redactBody(body: string | undefined, maxLen = 200): string {
  if (!body) return "(empty)";
  if (body.length <= maxLen) return body;
  return `${body.slice(0, maxLen)}…(truncated, totalLen=${body.length})`;
}

export function redactUrl(rawUrl: string): string {
  try {
    const u = new URL(rawUrl);
    const base = `${u.origin}${u.pathname}`;
    return u.search ? `${base}?<redacted>` : base;
  } catch {
    return truncate(rawUrl, 80);
  }
}

7.2 脱敏策略

  • Token:显示前 6 个字符,隐藏其余部分
  • 请求体:截断至 200 字符
  • URL:隐藏查询字符串(可能包含签名)
  • 空值:明确标记为 "(none)" 或 "(empty)"

八、ID 生成与随机数

8.1 消息 ID 生成

export function generateId(prefix: string): string {
  return `${prefix}:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
}

格式:{prefix}:{timestamp}-{8-char hex}

示例:openclaw-weixin:1711090800000-a1b2c3d4

8.2 临时文件名生成

export function tempFileName(prefix: string, ext: string): string {
  return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`;
}

格式:{prefix}-{timestamp}-{8-char hex}{ext}

示例:weixin-remote-1711090800000-a1b2c3d4.jpg

8.3 设计考量

  • 时间戳:确保基本的有序性
  • 随机数:防止冲突,增强不可预测性
  • 前缀:便于识别和分类
  • crypto 模块:使用加密安全的随机数生成

九、总结

OpenClaw WeChat 插件的 API 协议与数据流设计展现了以下特点:

  1. RESTful API:统一的 HTTP JSON 接口,易于理解和调试
  2. 长轮询机制:实现低延迟消息接收,同时保持简单性
  3. 类型安全:完整的 TypeScript 类型定义,编译时检查
  4. 同步缓冲:确保消息不丢失,支持断点续传
  5. 统一日志:与 OpenClaw 核心一致的日志格式,便于集中分析
  6. 安全脱敏:敏感信息自动脱敏,防止日志泄露
  7. ID 生成:时间戳+随机数的混合策略,兼顾有序性和唯一性

这些设计不仅保证了系统的稳定性和可靠性,也为开发者提供了清晰的接口契约和调试手段。在下一篇文章中,我们将探讨进阶开发与实践,包括调试技巧、性能优化和故障排查。

@tencent-weixin/openclaw-weixin 插件深度解析(二):消息处理系统架构

作者 毛骗导演
2026年3月22日 13:50

长轮询、入站/出站消息、斜杠命令

消息处理是即时通讯插件的核心能力。本文将深入剖析 OpenClaw WeChat 插件的消息处理系统,包括入站消息的处理流程、出站消息的发送机制、媒体文件的处理、斜杠命令系统以及错误处理与通知机制。通过详细的源码解读,帮助开发者理解其设计原理和实现细节。

一、消息处理架构概览

OpenClaw WeChat 插件的消息处理系统采用经典的"生产者-消费者"模式,结合长轮询机制实现实时消息收发:

┌─────────────────────────────────────────────────────────────────────────┐
│                         Message Processing Architecture                  │
├─────────────────────────────────────────────────────────────────────────┤
│  ┌──────────────┐      ┌──────────────┐      ┌──────────────────────┐  │
│  │   Monitor    │ ───> │   Process    │ ───> │     Dispatch         │  │
│  │  (Long Poll) │      │   Message    │      │   Reply Dispatcher   │  │
│  └──────────────┘      └──────────────┘      └──────────────────────┘  │
│         │                     │                         │               │
│         ▼                     ▼                         ▼               │
│  ┌──────────────┐      ┌──────────────┐      ┌──────────────────────┐  │
│  │ getUpdates   │      │ Media        │      │   AI Pipeline        │  │
│  │ Sync Buffer  │      │ Download     │      │   (Agent Reply)      │  │
│  └──────────────┘      └──────────────┘      └──────────────────────┘  │
├─────────────────────────────────────────────────────────────────────────┤
│                         Outbound Flow                                    │
├─────────────────────────────────────────────────────────────────────────┤
│  ┌──────────────┐      ┌──────────────┐      ┌──────────────────────┐  │
│  │   Deliver    │ ───> │   Upload     │ ───> │    Send Message      │  │
│  │   Callback   │      │   to CDN     │      │    (Weixin API)      │  │
│  └──────────────┘      └──────────────┘      └──────────────────────┘  │
└─────────────────────────────────────────────────────────────────────────┘

这种架构的优势在于:职责分离清晰,便于独立测试和维护;支持媒体文件的异步处理;通过长轮询实现低延迟消息接收;完善的错误处理和重试机制。

二、长轮询监控器(Monitor)

2.1 监控器核心循环

监控器是消息处理的入口,负责通过长轮询从微信服务器获取消息:

export async function monitorWeixinProvider(opts: MonitorWeixinOpts): Promise<void> {
  const {
    baseUrl,
    cdnBaseUrl,
    token,
    accountId,
    config,
    abortSignal,
    longPollTimeoutMs,
    setStatus,
  } = opts;
  const log = opts.runtime?.log ?? (() => {});
  const errLog = opts.runtime?.error ?? ((m: string) => log(m));
  const aLog: Logger = logger.withAccount(accountId);

  aLog.info(`waiting for Weixin runtime...`);
  let channelRuntime: PluginRuntime["channel"];
  try {
    const pluginRuntime = await waitForWeixinRuntime();
    channelRuntime = pluginRuntime.channel;
    aLog.info(`Weixin runtime acquired, channelRuntime type: ${typeof channelRuntime}`);
  } catch (err) {
    aLog.error(`waitForWeixinRuntime() failed: ${String(err)}`);
    throw err;
  }

  log(`weixin monitor started (${baseUrl}, account=${accountId})`);

监控器首先等待运行时初始化完成,这是与 OpenClaw 框架集成的关键步骤。

2.2 同步缓冲区管理

为了实现断点续传和消息不丢失,插件使用同步缓冲区(sync buffer)机制:

const syncFilePath = getSyncBufFilePath(accountId);
aLog.debug(`syncFilePath: ${syncFilePath}`);

const previousGetUpdatesBuf = loadGetUpdatesBuf(syncFilePath);
let getUpdatesBuf = previousGetUpdatesBuf ?? "";

if (previousGetUpdatesBuf) {
  log(`[weixin] resuming from previous sync buf (${getUpdatesBuf.length} bytes)`);
  aLog.debug(`Using previous get_updates_buf (${getUpdatesBuf.length} bytes)`);
} else {
  log(`[weixin] no previous sync buf, starting fresh`);
  aLog.info(`No previous get_updates_buf found, starting fresh`);
}

同步缓冲区的工作原理:

  1. 首次启动时,get_updates_buf 为空字符串
  2. 每次成功获取消息后,服务器返回新的 get_updates_buf
  3. 插件将其持久化到本地文件
  4. 重启后从文件恢复,确保消息连续性

2.3 长轮询与错误处理

监控器核心循环实现了完善的错误处理和退避策略:

while (!abortSignal?.aborted) {
  try {
    aLog.debug(
      `getUpdates: get_updates_buf=${getUpdatesBuf.substring(0, 50)}..., timeoutMs=${nextTimeoutMs}`,
    );
    const resp = await getUpdates({
      baseUrl,
      token,
      get_updates_buf: getUpdatesBuf,
      timeoutMs: nextTimeoutMs,
    });

    if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) {
      nextTimeoutMs = resp.longpolling_timeout_ms;
      aLog.debug(`Updated next poll timeout: ${nextTimeoutMs}ms`);
    }

    const isApiError =
      (resp.ret !== undefined && resp.ret !== 0) ||
      (resp.errcode !== undefined && resp.errcode !== 0);

    if (isApiError) {
      const isSessionExpired =
        resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;

      if (isSessionExpired) {
        pauseSession(accountId);
        const pauseMs = getRemainingPauseMs(accountId);
        errLog(
          `weixin getUpdates: session expired (errcode ${SESSION_EXPIRED_ERRCODE}), pausing bot for ${Math.ceil(pauseMs / 60_000)} min`,
        );
        consecutiveFailures = 0;
        await sleep(pauseMs, abortSignal);
        continue;
      }

      consecutiveFailures += 1;
      if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
        errLog(
          `weixin getUpdates: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`,
        );
        consecutiveFailures = 0;
        await sleep(BACKOFF_DELAY_MS, abortSignal);
      } else {
        await sleep(RETRY_DELAY_MS, abortSignal);
      }
      continue;
    }

    consecutiveFailures = 0;
    setStatus?.({ accountId, lastEventAt: Date.now() });

    if (resp.get_updates_buf != null && resp.get_updates_buf !== "") {
      saveGetUpdatesBuf(syncFilePath, resp.get_updates_buf);
      getUpdatesBuf = resp.get_updates_buf;
      aLog.debug(`Saved new get_updates_buf (${getUpdatesBuf.length} bytes)`);
    }

    const list = resp.msgs ?? [];
    for (const full of list) {
      // 处理每条消息...
    }
  } catch (err) {
    // 异常处理...
  }
}

错误处理策略包括:

  • 会话过期:暂停该账号 1 小时,避免频繁请求导致封号
  • 连续失败:最多容忍 3 次连续失败,之后退避 30 秒
  • 一般错误:2 秒后重试
  • 优雅退出:响应 abortSignal,确保资源正确释放

三、入站消息处理流程

3.1 消息处理入口

processOneMessage 是入站消息处理的核心函数,负责完整的处理流水线:

export async function processOneMessage(
  full: WeixinMessage,
  deps: ProcessMessageDeps,
): Promise<void> {
  if (!deps?.channelRuntime) {
    logger.error(
      `processOneMessage: channelRuntime is undefined, skipping message from=${full.from_user_id}`,
    );
    deps.errLog("processOneMessage: channelRuntime is undefined, skip");
    return;
  }

  const receivedAt = Date.now();
  const debug = isDebugMode(deps.accountId);
  const debugTrace: string[] = [];
  const debugTs: Record<string, number> = { received: receivedAt };

3.2 斜杠命令处理

在处理 AI 回复之前,首先检查是否是斜杠命令:

const textBody = extractTextBody(full.item_list);
if (textBody.startsWith("/")) {
  const slashResult = await handleSlashCommand(textBody, {
    to: full.from_user_id ?? "",
    contextToken: full.context_token,
    baseUrl: deps.baseUrl,
    token: deps.token,
    accountId: deps.accountId,
    log: deps.log,
    errLog: deps.errLog,
  }, receivedAt, full.create_time_ms);
  if (slashResult.handled) {
    logger.info(`[weixin] Slash command handled, skipping AI pipeline`);
    return;
  }
}

斜杠命令系统允许用户执行一些快捷操作,如 /echo/toggle-debug,这些命令直接响应,不经过 AI 处理管道。

3.3 媒体文件下载

微信消息可能包含图片、视频、文件或语音等媒体内容。插件需要下载并解密这些文件:

const mediaOpts: WeixinInboundMediaOpts = {};

// Find the first downloadable media item (priority: IMAGE > VIDEO > FILE > VOICE).
// When none found in the main item_list, fall back to media referenced via a quoted message.
const mainMediaItem =
  full.item_list?.find(
    (i) => i.type === MessageItemType.IMAGE && i.image_item?.media?.encrypt_query_param,
  ) ??
  full.item_list?.find(
    (i) => i.type === MessageItemType.VIDEO && i.video_item?.media?.encrypt_query_param,
  ) ??
  full.item_list?.find(
    (i) => i.type === MessageItemType.FILE && i.file_item?.media?.encrypt_query_param,
  ) ??
  full.item_list?.find(
    (i) =>
      i.type === MessageItemType.VOICE &&
      i.voice_item?.media?.encrypt_query_param &&
      !i.voice_item.text,
  );

const refMediaItem = !mainMediaItem
  ? full.item_list?.find(
      (i) =>
        i.type === MessageItemType.TEXT &&
        i.ref_msg?.message_item &&
        isMediaItem(i.ref_msg.message_item!),
    )?.ref_msg?.message_item
  : undefined;

const mediaDownloadStart = Date.now();
const mediaItem = mainMediaItem ?? refMediaItem;
if (mediaItem) {
  const label = refMediaItem ? "ref" : "inbound";
  const downloaded = await downloadMediaFromItem(mediaItem, {
    cdnBaseUrl: deps.cdnBaseUrl,
    saveMedia: deps.channelRuntime.media.saveMediaBuffer,
    log: deps.log,
    errLog: deps.errLog,
    label,
  });
  Object.assign(mediaOpts, downloaded);
}
const mediaDownloadMs = Date.now() - mediaDownloadStart;

媒体处理的优先级设计:

  1. 主消息媒体:优先处理消息本身附带的媒体
  2. 引用消息媒体:如果主消息没有媒体,检查是否引用了媒体消息
  3. 类型优先级:图片 > 视频 > 文件 > 语音
  4. 语音特殊处理:如果语音已转文字(有 text 字段),跳过下载

3.4 用户授权检查

在将消息路由给 AI 之前,需要检查发送者是否有权限:

const { senderAllowedForCommands, commandAuthorized } =
  await resolveSenderCommandAuthorizationWithRuntime({
    cfg: deps.config,
    rawBody,
    isGroup: false,
    dmPolicy: "pairing",
    configuredAllowFrom: [],
    configuredGroupAllowFrom: [],
    senderId,
    isSenderAllowed: (id: string, list: string[]) => list.length === 0 || list.includes(id),
    readAllowFromStore: async () => {
      const fromStore = readFrameworkAllowFromList(deps.accountId);
      if (fromStore.length > 0) return fromStore;
      const uid = loadWeixinAccount(deps.accountId)?.userId?.trim();
      return uid ? [uid] : [];
    },
    runtime: deps.channelRuntime.commands,
  });

const directDmOutcome = resolveDirectDmAuthorizationOutcome({
  isGroup: false,
  dmPolicy: "pairing",
  senderAllowedForCommands,
});

if (directDmOutcome === "disabled" || directDmOutcome === "unauthorized") {
  logger.info(
    `authorization: dropping message from=${senderId} outcome=${directDmOutcome}`,
  );
  return;
}

授权检查采用"配对"(pairing)模式:

  • 只有通过 QR 码登录授权的用户才能与 Bot 交互
  • 授权列表存储在框架的 allowFrom 文件中
  • 支持向后兼容:如果没有配对文件,使用登录时的 userId 作为备选

3.5 消息路由与会话管理

通过 OpenClaw 框架的路由系统,确定消息应该由哪个 Agent 处理:

const route = deps.channelRuntime.routing.resolveAgentRoute({
  cfg: deps.config,
  channel: "openclaw-weixin",
  accountId: deps.accountId,
  peer: { kind: "direct", id: ctx.To },
});

logger.debug(
  `resolveAgentRoute: agentId=${route.agentId ?? "(none)"} sessionKey=${route.sessionKey ?? "(none)"} mainSessionKey=${route.mainSessionKey ?? "(none)"}`,
);

if (!route.agentId) {
  logger.error(
    `resolveAgentRoute: no agentId resolved for peer=${ctx.To} accountId=${deps.accountId} — message will not be dispatched`,
  );
}

ctx.SessionKey = route.sessionKey;
const storePath = deps.channelRuntime.session.resolveStorePath(deps.config.session?.store, {
  agentId: route.agentId,
});
const finalized = deps.channelRuntime.reply.finalizeInboundContext(ctx);

路由解析后,消息上下文被"最终化"(finalize),准备进入 AI 处理管道。

3.6 入站会话记录

将消息记录到会话存储,用于维护对话上下文:

await deps.channelRuntime.session.recordInboundSession({
  storePath,
  sessionKey: route.sessionKey,
  ctx: finalized,
  updateLastRoute: {
    sessionKey: route.mainSessionKey,
    channel: "openclaw-weixin",
    to: ctx.To,
    accountId: deps.accountId,
  },
  onRecordError: (err) => deps.errLog(`recordInboundSession: ${String(err)}`),
});

const contextToken = getContextTokenFromMsgContext(ctx);
if (contextToken) {
  setContextToken(deps.accountId, full.from_user_id ?? "", contextToken);
}

这里同时缓存了 context_token,这是后续回复消息时必需的参数。

四、出站消息发送机制

4.1 回复分发器

OpenClaw 框架提供了回复分发器(Reply Dispatcher)机制,用于管理 AI 生成的回复:

const humanDelay = deps.channelRuntime.reply.resolveHumanDelayConfig(deps.config, route.agentId);

const hasTypingTicket = Boolean(deps.typingTicket);
const typingCallbacks = createTypingCallbacks({
  start: hasTypingTicket
    ? () =>
        sendTyping({
          baseUrl: deps.baseUrl,
          token: deps.token,
          body: {
            ilink_user_id: ctx.To,
            typing_ticket: deps.typingTicket!,
            status: TypingStatus.TYPING,
          },
        })
    : async () => {},
  stop: hasTypingTicket
    ? () =>
        sendTyping({
          baseUrl: deps.baseUrl,
          token: deps.token,
          body: {
            ilink_user_id: ctx.To,
            typing_ticket: deps.typingTicket!,
            status: TypingStatus.CANCEL,
          },
        })
    : async () => {},
  onStartError: (err) => deps.log(`[weixin] typing send error: ${String(err)}`),
  onStopError: (err) => deps.log(`[weixin] typing cancel error: ${String(err)}`),
  keepaliveIntervalMs: 5000,
});

打字指示器(Typing Indicator)通过 typingTicket 实现,让用户体验更加自然。

4.2 消息投递回调

deliver 回调函数负责实际的消息发送:

const { dispatcher, replyOptions, markDispatchIdle } =
  deps.channelRuntime.reply.createReplyDispatcherWithTyping({
    humanDelay,
    typingCallbacks,
    deliver: async (payload) => {
      const text = markdownToPlainText(payload.text ?? "");
      const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];

      logger.debug(`outbound payload: ${redactBody(JSON.stringify(payload))}`);
      logger.info(
        `outbound: to=${ctx.To} contextToken=${redactToken(contextToken)} textLen=${text.length} mediaUrl=${mediaUrl ? "present" : "none"}`,
      );

      try {
        if (mediaUrl) {
          let filePath: string;
          if (!mediaUrl.includes("://") || mediaUrl.startsWith("file://")) {
            // Local path handling
            if (mediaUrl.startsWith("file://")) {
              filePath = new URL(mediaUrl).pathname;
            } else if (!path.isAbsolute(mediaUrl)) {
              filePath = path.resolve(mediaUrl);
            } else {
              filePath = mediaUrl;
            }
          } else if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) {
            filePath = await downloadRemoteImageToTemp(mediaUrl, MEDIA_OUTBOUND_TEMP_DIR);
          } else {
            await sendMessageWeixin({ to: ctx.To, text, opts: {
              baseUrl: deps.baseUrl,
              token: deps.token,
              contextToken,
            }});
            return;
          }
          await sendWeixinMediaFile({
            filePath,
            to: ctx.To,
            text,
            opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken },
            cdnBaseUrl: deps.cdnBaseUrl,
          });
        } else {
          await sendMessageWeixin({ to: ctx.To, text, opts: {
            baseUrl: deps.baseUrl,
            token: deps.token,
            contextToken,
          }});
        }
      } catch (err) {
        logger.error(`outbound: FAILED to=${ctx.To} err=${String(err)}`);
        throw err;
      }
    },
    onError: (err, info) => {
      // Error handling...
    },
  });

投递逻辑支持多种媒体来源:

  • 本地文件:绝对路径、相对路径或 file:// URL
  • 远程 URL:自动下载到临时目录
  • 纯文本:直接发送文字消息

4.3 Markdown 转纯文本

AI 生成的回复通常是 Markdown 格式,需要转换为纯文本以适应微信:

export function markdownToPlainText(text: string): string {
  let result = text;
  // Code blocks: strip fences, keep code content
  result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, code: string) => code.trim());
  // Images: remove entirely
  result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, "");
  // Links: keep display text only
  result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
  // Tables: remove separator rows, then strip leading/trailing pipes
  result = result.replace(/^\|[\s:|-]+\|$/gm, "");
  result = result.replace(/^\|(.+)\|$/gm, (_, inner: string) =>
    inner.split("|").map((cell) => cell.trim()).join("  "),
  );
  result = stripMarkdown(result);
  return result;
}

转换规则包括:

  • 代码块:保留代码内容,去除围栏标记
  • 图片:完全移除(图片会作为独立媒体发送)
  • 链接:保留显示文本,去除 URL
  • 表格:转换为文本格式

五、媒体文件处理

5.1 媒体发送流程

sendWeixinMediaFile 函数根据文件类型选择不同的上传和发送策略:

export async function sendWeixinMediaFile(params: {
  filePath: string;
  to: string;
  text: string;
  opts: WeixinApiOptions & { contextToken?: string };
  cdnBaseUrl: string;
}): Promise<{ messageId: string }> {
  const { filePath, to, text, opts, cdnBaseUrl } = params;
  const mime = getMimeFromFilename(filePath);
  const uploadOpts: WeixinApiOptions = { baseUrl: opts.baseUrl, token: opts.token };

  if (mime.startsWith("video/")) {
    const uploaded = await uploadVideoToWeixin({
      filePath,
      toUserId: to,
      opts: uploadOpts,
      cdnBaseUrl,
    });
    return sendVideoMessageWeixin({ to, text, uploaded, opts });
  }

  if (mime.startsWith("image/")) {
    const uploaded = await uploadFileToWeixin({
      filePath,
      toUserId: to,
      opts: uploadOpts,
      cdnBaseUrl,
    });
    return sendImageMessageWeixin({ to, text, uploaded, opts });
  }

  // File attachment: pdf, doc, zip, etc.
  const fileName = path.basename(filePath);
  const uploaded = await uploadFileAttachmentToWeixin({
    filePath,
    fileName,
    toUserId: to,
    opts: uploadOpts,
    cdnBaseUrl,
  });
  return sendFileMessageWeixin({ to, text, fileName, uploaded, opts });
}

5.2 图片消息构建

图片消息需要包含加密参数和 AES 密钥:

export async function sendImageMessageWeixin(params: {
  to: string;
  text: string;
  uploaded: UploadedFileInfo;
  opts: WeixinApiOptions & { contextToken?: string };
}): Promise<{ messageId: string }> {
  const { to, text, uploaded, opts } = params;
  if (!opts.contextToken) {
    throw new Error("sendImageMessageWeixin: contextToken is required");
  }

  const imageItem: MessageItem = {
    type: MessageItemType.IMAGE,
    image_item: {
      media: {
        encrypt_query_param: uploaded.downloadEncryptedQueryParam,
        aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
        encrypt_type: 1,
      },
      mid_size: uploaded.fileSizeCiphertext,
    },
  };

  return sendMediaItems({ to, text, mediaItem: imageItem, opts, label: "sendImageMessageWeixin" });
}

5.3 媒体项发送

当消息同时包含文字和媒体时,分别发送为独立的消息项:

async function sendMediaItems(params: {
  to: string;
  text: string;
  mediaItem: MessageItem;
  opts: WeixinApiOptions & { contextToken?: string };
  label: string;
}): Promise<{ messageId: string }> {
  const { to, text, mediaItem, opts, label } = params;

  const items: MessageItem[] = [];
  if (text) {
    items.push({ type: MessageItemType.TEXT, text_item: { text } });
  }
  items.push(mediaItem);

  let lastClientId = "";
  for (const item of items) {
    lastClientId = generateClientId();
    const req: SendMessageReq = {
      msg: {
        from_user_id: "",
        to_user_id: to,
        client_id: lastClientId,
        message_type: MessageType.BOT,
        message_state: MessageState.FINISH,
        item_list: [item],
        context_token: opts.contextToken ?? undefined,
      },
    };
    await sendMessageApi({
      baseUrl: opts.baseUrl,
      token: opts.token,
      timeoutMs: opts.timeoutMs,
      body: req,
    });
  }

  return { messageId: lastClientId };
}

六、斜杠命令系统

6.1 命令处理架构

斜杠命令系统提供了一种快捷方式,让用户可以直接执行特定操作:

export async function handleSlashCommand(
  content: string,
  ctx: SlashCommandContext,
  receivedAt: number,
  eventTimestamp?: number,
): Promise<SlashCommandResult> {
  const trimmed = content.trim();
  if (!trimmed.startsWith("/")) {
    return { handled: false };
  }

  const spaceIdx = trimmed.indexOf(" ");
  const command = spaceIdx === -1 ? trimmed.toLowerCase() : trimmed.slice(0, spaceIdx).toLowerCase();
  const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1);

  logger.info(`[weixin] Slash command: ${command}, args: ${args.slice(0, 50)}`);

  try {
    switch (command) {
      case "/echo":
        await handleEcho(ctx, args, receivedAt, eventTimestamp);
        return { handled: true };
      case "/toggle-debug": {
        const enabled = toggleDebugMode(ctx.accountId);
        await sendReply(ctx, enabled ? "Debug 模式已开启" : "Debug 模式已关闭");
        return { handled: true };
      }
      default:
        return { handled: false };
    }
  } catch (err) {
    logger.error(`[weixin] Slash command error: ${String(err)}`);
    try {
      await sendReply(ctx, `❌ 指令执行失败: ${String(err).slice(0, 200)}`);
    } catch {
      // 发送错误消息也失败了
    }
    return { handled: true };
  }
}

6.2 Echo 命令实现

/echo 命令用于测试通道延迟:

async function handleEcho(
  ctx: SlashCommandContext,
  args: string,
  receivedAt: number,
  eventTimestamp?: number,
): Promise<void> {
  const message = args.trim();
  if (message) {
    await sendReply(ctx, message);
  }
  const eventTs = eventTimestamp ?? 0;
  const platformDelay = eventTs > 0 ? `${receivedAt - eventTs}ms` : "N/A";
  const timing = [
    "⏱ 通道耗时",
    `├ 事件时间: ${eventTs > 0 ? new Date(eventTs).toISOString() : "N/A"}`,
    `├ 平台→插件: ${platformDelay}`,
    `└ 插件处理: ${Date.now() - receivedAt}ms`,
  ].join("\n");
  await sendReply(ctx, timing);
}

6.3 Debug 模式切换

/toggle-debug 命令用于开关调试模式:

export function toggleDebugMode(accountId: string): boolean {
  const state = loadState();
  const next = !state.accounts[accountId];
  state.accounts[accountId] = next;
  try {
    saveState(state);
  } catch (err) {
    logger.error(`debug-mode: failed to persist state: ${String(err)}`);
  }
  return next;
}

调试模式状态持久化到磁盘,确保网关重启后设置不丢失。

七、错误处理与通知

7.1 错误分类与处理

消息发送失败时,系统会尝试向用户发送错误通知:

onError: (err, info) => {
  deps.errLog(`weixin reply ${info.kind}: ${String(err)}`);
  const errMsg = err instanceof Error ? err.message : String(err);
  let notice: string;
  if (errMsg.includes("contextToken is required")) {
    logger.warn(`onError: contextToken missing, cannot send error notice to=${ctx.To}`);
    return;
  } else if (errMsg.includes("remote media download failed") || errMsg.includes("fetch")) {
    notice = `⚠️ 媒体文件下载失败,请检查链接是否可访问。`;
  } else if (
    errMsg.includes("getUploadUrl") ||
    errMsg.includes("CDN upload") ||
    errMsg.includes("upload_param")
  ) {
    notice = `⚠️ 媒体文件上传失败,请稍后重试。`;
  } else {
    notice = `⚠️ 消息发送失败:${errMsg}`;
  }
  void sendWeixinErrorNotice({
    to: ctx.To,
    contextToken,
    message: notice,
    baseUrl: deps.baseUrl,
    token: deps.token,
    errLog: deps.errLog,
  });
}

7.2 错误通知发送

错误通知采用"fire-and-forget"模式,不影响主流程:

export async function sendWeixinErrorNotice(params: {
  to: string;
  contextToken: string | undefined;
  message: string;
  baseUrl: string;
  token?: string;
  errLog: (m: string) => void;
}): Promise<void> {
  if (!params.contextToken) {
    logger.warn(`sendWeixinErrorNotice: no contextToken for to=${params.to}, cannot notify user`);
    return;
  }
  try {
    await sendMessageWeixin({ to: params.to, text: params.message, opts: {
      baseUrl: params.baseUrl,
      token: params.token,
      contextToken: params.contextToken,
    }});
    logger.debug(`sendWeixinErrorNotice: sent to=${params.to}`);
  } catch (err) {
    params.errLog(`[weixin] sendWeixinErrorNotice failed to=${params.to}: ${String(err)}`);
  }
}

八、调试模式与性能追踪

8.1 全链路耗时统计

当调试模式开启时,插件会在每条 AI 回复后追加详细的耗时统计:

if (debug && contextToken) {
  const dispatchDoneAt = Date.now();
  const eventTs = full.create_time_ms ?? 0;
  const platformDelay = eventTs > 0 ? `${receivedAt - eventTs}ms` : "N/A";
  const inboundProcessMs = (debugTs.preDispatch ?? receivedAt) - receivedAt;
  const aiMs = dispatchDoneAt - (debugTs.preDispatch ?? receivedAt);
  const totalTime = eventTs > 0 ? `${dispatchDoneAt - eventTs}ms` : `${dispatchDoneAt - receivedAt}ms`;

  debugTrace.push(
    "── 耗时 ──",
    `├ 平台→插件: ${platformDelay}`,
    `├ 入站处理(auth+route+media): ${inboundProcessMs}ms (mediaDownload: ${mediaDownloadMs}ms)`,
    `├ AI生成+回复: ${aiMs}ms`,
    `├ 总耗时: ${totalTime}`,
    `└ eventTime: ${eventTs > 0 ? new Date(eventTs).toISOString() : "N/A"}`,
  );

  const timingText = `⏱ Debug 全链路\n${debugTrace.join("\n")}`;
  await sendMessageWeixin({
    to: ctx.To,
    text: timingText,
    opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken },
  });
}

8.2 调试追踪信息

调试模式下会记录完整的处理轨迹:

if (debug) {
  const itemTypes = full.item_list?.map((i) => i.type).join(",") ?? "none";
  debugTrace.push(
    "── 收消息 ──",
    `│ seq=${full.seq ?? "?"} msgId=${full.message_id ?? "?"} from=${full.from_user_id ?? "?"}`,
    `│ body="${textBody.slice(0, 40)}${textBody.length > 40 ? "…" : ""}" (len=${textBody.length}) itemTypes=[${itemTypes}]`,
    `│ sessionId=${full.session_id ?? "?"} contextToken=${full.context_token ? "present" : "none"}`,
  );
}

九、总结

OpenClaw WeChat 插件的消息处理系统展现了以下设计亮点:

  1. 长轮询架构:实现低延迟消息接收,支持断点续传
  2. 分层处理:监控、处理、发送职责分离,便于维护
  3. 媒体处理:支持多种媒体类型,自动下载解密
  4. 授权机制:基于配对的用户授权,确保安全性
  5. 错误恢复:完善的错误处理和用户通知机制
  6. 调试支持:全链路耗时追踪,便于性能优化

这些设计不仅保证了系统的稳定性和可靠性,也为开发者提供了丰富的调试和监控手段。在下一篇文章中,我们将深入探讨 CDN 媒体服务系统的加密与上传机制。

“我要验牌”很火吗?我特意写了个Shader去验...

2026年3月23日 09:17

电影截屏

引言

哈喽大家好,我是亿元程序员,相信大家都看或者听到过下面几句台词:

“我要验牌(wò yāo yān pǎi)”。

“牌没有问题”。

“给我擦皮鞋”。

如果要评选马年开年第一热梗,这几句台词估计能够遥遥领先。

为此,我特意写了一个Shader来"验牌"(蹭热度是吧?)。

言归正传,本期带大家一起来看看如何在Cocos游戏开发中,通过Shader实现卡牌的掀角(搓牌)效果,进行“验牌”

本文源工程可在文末获取,小伙伴们自行前往。

什么是掀角(搓牌)效果?

卡牌从一个角 / 边开始,以卷曲、倾斜、透视变形的方式,从背面逐渐翻转为正面,过程中牌面局部先露出、整体渐进展开,模拟真实纸张的弯曲与光影变化。

还是不懂?我斥巨资买了一副扑克牌来演示给大家看:

验给大家看

原来如此,但是游戏中要它何用?

掀角(搓牌)效果有什么用?

掀角(搓牌)效果不是花里胡哨,是专门用来提升 “手感、悬念、运营数据” 的,在棋牌、卡牌游戏里属于非常实用的设计。

总的来说有以下几种常见用处:

  • 提升真实手感 : 模拟现实里用手指搓开牌角偷看的动作,更像 “真打牌”,在棋牌和卡牌游戏中很常见。
  • 制造悬念,强化情绪 :不是一下子翻开,而是先露一点、再慢慢展开,在抽卡类游戏中很常见,常用作动画效果。
  • 循序渐进 :模拟翻书效果,逐渐展示全部内容,避免直白刷新,常用来展开新篇章。

好哦好哦,那要怎么实现呢?

掀角(搓牌)效果实战

这部分会比较啰嗦,小伙伴们可以直接跳到末尾四键四连点赞分享收藏转发

1.前期准备

首先准备正反面的扑克牌,有条件的可以买扑克自己拍,我直接去找AI搭子:

你这是扑克牌吗

然后CocosCreator3.8.7创建一个名叫woyaoyanpai2D项目,将准备好的图片放到assets目录下,简单布置场景,此处小3埋下伏笔。

买家秀

老规矩,我们先找到Shader的模板,为什么不用菜单生成?因为那不适用。

在资源管理器,搜索sprite找到内置的spriteshader,然后复制到assets目录,改个名字woyaoyanpai

让它抄

2.初见端倪

Cocos Shader的固定格式通常分成以下3个部分:

  • 声明参数和渲染状态:定义渲染配置(混合模式、深度测试、属性参数等)。
  • 顶点着色器:决定每个顶点的位置和UV(纹理坐标)。
  • 片元着色器:决定每个像素的最终颜色。

正如笔者前面说过的,Shader入门阶段理解这三部分就足够上手,复杂效果可以结合AI辅助实现。。

下面我们将根据上面的三部分分别进行。

3.CCEffect(参数和渲染规则声明)

这部分是“配置项”,小伙伴们不用深钻细节,重点看properties里的参数(脚本会给这些参数传值):

划重点

具体如下

参数名 作用
faceTexture 卡牌正面的贴图(比如牌面的花色 / 数字)
faceUVFlip 正面贴图的翻转(比如牌面左右 / 上下反了,用这个修正)
touchPos 手指 / 鼠标拖拽的位置(UV 坐标,0~1 范围)
cornerPos 卡牌被掀起的角的位置(比如右下角是 (1,0),UV 坐标)
radius 卷曲的弧度(值越大,卷曲越明显)
paddingData 扩充范围数据(x/y 是向四周扩的像素,z/w 是卡牌宽高)

4.sprite-fs(片元着色器)

因为掀角(搓牌)主要在片元着色器实现,因此我们先来看看这部分。

片元着色器,粗暴地理解就是,它决定每个像素是什么颜色。

因此我们需要知道什么时候显示牌面,什么时候显示牌背才能知道显示什么颜色。

卷

卡片卷起来的时候,这里面涉及到一个数学模型叫“卷曲坐标系”。

看不懂别慌,问题不大

其中具体的含义如下:

变量 含义
dir 卷曲方向(和我们理解的方向相反)
D 拖拽距离(手指到角起点距离)
R 圆柱半径(卷起来的圆柱半径)

可以把卡牌卷曲理解成“绕一个圆柱滚起来”。

  • D:表示当前拖拽长度
  • 圆周公式:C = 2πR
  • 我们只卷一部分,所以用 D ≈ πR(半圈)

因此R ≈ D / π

这样就能根据拖拽距离动态算出卷曲半径。

建立完成以后,我们可以算出来当前渲染像素点相对于“卷曲交界线”的垂直距离x(投影),然后我们就能够根据x的值来进行分区,得出最终颜色。

看不懂要谎,问题很大

为了让小伙伴们更加容易理解,我给每一层叠加一层半透明的颜色(红、绿,蓝,黄),方便大家理解。

这下好理解了吧

5.sprite-vs(顶点着色器)

小伙伴可能会有疑问,什么时候应该在顶点着色器写,什么时候应该在片元着色器写。

先上一个简单的结论(其实后面还会涉及计算量问题,暂不讨论):

  • 顶点着色器(VS):改“形状”。
  • 片元着色器(FS):改“像素颜色”。

我们的效果有什么需要在顶点着色器完成的,直接给大家看下效果:

被裁了

对于一个Sprite,渲染范围就四个顶点,所以当像素出界后,无法正常显示。

因此我们需要在顶点着色器把范围扩大一下。

根据顶点位置扩大

扩大之后,图像会被拉伸,因此我们还要把UV缩回去,这样才能保证不变形。

相对运动

6.最终效果

原来是小瘪三

结语

我要验牌”这个梗最近的确很火,终于是烧到了游戏开发。

适度玩梗益脑,沉迷玩梗伤身。

本文实例工程可通过私信发送“我要验牌”获取。

更多实战完整源码包含编辑器已集成到亿元Cocos小游戏实战合集(已完结),感谢小伙伴们对创作的支持。


我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。

实不相瞒,想要个爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!

推荐文章:

亿元Cocos小游戏实战合集

老板说最近这款游戏很火让我抄,可是我连玩都玩不明白...

这款值68亿的游戏,你不实战一下吗?安排!

小伙伴说我的拼图游戏用Mask不能合批...

俄罗斯方块谁不会做......啊?流沙版?

最近很火的一个拼图游戏,老板让我用Cocos3.8做一个...

老板说拼图游戏太卷了,让我用Cocos做个3d版本的...

敢不敢挑战用Cocos3.8复刻曾经很火的割绳子游戏?

面了3个人后我发现:AI用得最溜的,未必是我最想要的工程师

作者 李逸君
2026年3月23日 08:54

这两周,我面试了 3 个人,技术岗。

现在聊技术面试,很多人默认一个前提:

大家都想要那种 AI 用得很溜的人。

但我实际聊下来,发现真正需要的,未必只是这种人。

真正稀缺的,是有判断力的人。

原因很简单。

怎么用 AI,其实不难。

真正难的,是你能不能想到:这东西原来还能这么玩。

所以我面试一个人,看的已经不只是会不会写代码了。

我会更关注两件事。

第一,他有没有真正搞懂自己的项目到底是怎么跑起来的。

第二,他借助 AI,已经能做到什么程度。

因为 AI 工具换得太快了。

今天你熟这个,明天可能就冒出来一个更强的。

产品化的 AI 工具,上手门槛其实非常低。很多时候根本不需要培训一两天,可能装上就能开始干活。真正的门槛,反而变成了愿不愿意用,或者怎么把它装起来。

我面试的一个人,就是很典型的例子。

5 年经验,使用 AI 写代码非常快,效率也确实高,已经能干过去两个自己干的活。

乍一看,这种人很亮眼。

但我往下问了一层,问题就出来了。

工具是公司给的。

他对 AI 为什么能做到这件事,几乎不知道。

再继续问项目本身是怎么运转起来的,也没有想象中那么清楚。

更关键的是,在 AI 带来的提效之后,他原本的技术成长,反而停住了。

而且这一停,就是 1 年。

最后呈现出来的状态就是:能力和年限不匹配。

他能提效的核心原因,不是自己突然变得特别厉害,而是公司提供的工具真的很好用。

换句话说,他没有形成真正属于自己的能力。

但现在这个环境,资历浅的开发工程师,如果还想单靠传统技术路径往上升,其实已经越来越难了。

不是说技术不重要了。

而是现实情况是,很多人已经没有那么多机会,去亲手解决那些真正有深度的问题。

就好比每次课后作业,你都可以参考班里那个学习最好的同学,老师也只要求你把结果交上去就行。

在这种环境里,你当然能完成任务。

但如果哪天碰到一道连那个好学生都不会的题,你真的还能指望自己上场就更强吗?

相比之下,我面试的另外一个人,反倒是我更愿意高看一眼的类型。

8 年经验,因为行业限制,他没有很深入地接触 AI。

如果只看这个点,他不算亮眼。

但他的技术底子很扎实,解决问题的思路也很清楚。很多东西你一问,就知道底子在,很多能力是实打实沉淀过的。

更重要的是,他不是排斥 AI。只是之前没有足够多的机会,系统地去用。

我问了他一个问题:如果你有机会提效,但是要付出成本。你每个月愿意最多出多少预算?

每个人心里有自己的一杆秤。他的回答我认同。

所以我不会因为他现在 AI 这块不够亮眼,就刻意把评价压低。

相反,我会按照他真实的技术能力去评价他。

原因也很简单。

AI 这件事,学起来没有很多人想得那么玄。

尤其对于一个技术基本功强的人来说,他理解 AI、接住 AI、用好 AI 的成本,只会比别人更低。

这类人最缺的,很多时候不是能力,而是一个真正拥抱 AI 的决心。

很多高级工程师的问题,也不在能力,而在心理上。

这类人太容易相信自己过去那套路径了,也太容易依赖原有经验了。

结果就是,AI 他们不是不会用,而是总用得别别扭扭,总比别人慢半拍。

说白了,就是不太愿意把自己重新当成新人。

但偏偏,AI 时代最强的一批高级工程师,恰恰是那些愿意重新归零的人。

你越强,越要学会承认一件事:

有些问题,AI 解决得就是比你快,比你广,比你不知疲倦。

这不丢人。

跟计算器较劲的人,最后往往不是数学家,而是算盘手。

那么问题来了。

绝大多数人并没有很强的技术功底,以后又越来越少有机会,去独立解决复杂问题。

那怎么办?

我觉得,未来评判一个工程师,至少会越来越看重两个标准。

第一,一个人借助 AI,自己能代替过去多少人古法手搓的活。

第二,一个人借助 AI,能够帮助多少人提升效率。

一个是给自己提效。

一个是给别人提效。

显然,第一个方向更适合大部分人。

它有点像在美国大平原种田。

当机械化工具来了之后,就不再需要那么多传统农民了。一个农场主,可能就能干过去 100 个农民的工作量。

放到工程师身上,其实也是一样的。

借助 AI,一个人未来能吃下的工作量,一定会越来越大。

但这个方向也更卷,更残酷。

因为这个行业里的“农民”太多了,而未来未必还需要这么多人。

甚至很多后发的人,反而可能更有优势。

因为他们天生包袱更少,也总能更快接住新工具,精力也更充沛。

给自己提效还有一个思路,就是把手往上游和下游伸。

去抢那些其他岗位的饭碗,比如产品经理、项目主管、测试、运维等这些角色。

说得再直接一点:

如果你能做他们做的事情,还能把整条链路的事情一起做完,那企业为什么还要按原来的方式配那么多人?

这其实就是一种降维打击。

写到这里,其实我真正想说的,只有一句话:

AI 时代当然要学 AI。

但别把“会用 AI”,误以为是全部。

因为工具会越来越强,教程会越来越多,门槛会越来越低。

真正拉开差距的,依然还是那些更底层的东西。

比如你的判断力。

比如你的问题拆解能力。

比如你的架构思维。

比如你的学习能力。

以及最重要的一点:

你有没有勇气,重新把自己当成一个新人。

真正厉害的人,不是手里拿着多少个 AI 工具的人。

而是即使工具天天在变,也总能把问题解决掉的人。

这种人,放在什么时候,都不会太差。

2026年最值得关注的JavaScript新特性:Signals,响应式编程的下一个十年

2026年3月23日 08:52

ScreenShot_2026-03-22_105410_567.png

从框架之争到语言标准,响应式编程的下一个十年已经到来

在现代前端开发中,响应式编程早已成为构建用户界面的核心范式。无论是React的Hooks、Vue的组合式API,还是SolidJS的细粒度更新,它们的底层都离不开一个共同的原理——Signals

如今,TC39委员会正在讨论将Signals纳入JavaScript语言本身。尽管目前它还处于Stage 1的早期阶段,但我们已经看到越来越多的框架开始以Signals模式重构其响应式核心。本文将深入介绍Signals是什么、为什么重要,以及它如何改变我们编写JavaScript的方式。


一、Signals是什么?

Signals是一种响应式状态原语——它表示一个随时间变化的值,并能够自动通知所有依赖这个值的代码进行更新。

可以把它理解为一个带有“自动订阅”功能的特殊变量。当你读取它的值时,当前执行的上下文(比如组件渲染函数)会自动订阅这个Signal;当你修改它的值时,所有订阅者会自动重新执行。

核心API

目前提案中的Signals提供三个核心概念:

概念 说明
Signal.State 可变的信号,持有状态值
Signal.Computed 派生信号,基于其他信号计算值,自动缓存
Signal.subtle.Watcher 观察者,用于批量响应信号变化(框架层使用)

最简单的示例

// 创建一个状态信号,初始值为0
const count = new Signal.State(0);

// 创建一个计算信号,依赖于count
const doubled = new Signal.Computed(() => count.get() * 2);

// 创建一个effect,当依赖的信号变化时自动执行
const watcher = new Signal.subtle.Watcher(() => {
  console.log(`count = ${count.get()}, doubled = ${doubled.get()}`);
});
watcher.watch(count, doubled);

// 触发更新
count.set(1); // 控制台输出: count = 1, doubled = 2
count.set(2); // 输出: count = 2, doubled = 4

这段代码展示了Signals的核心能力:

  • 状态count 是可变的
  • 派生doubled 自动跟随 count 变化
  • 响应:任何依赖 countdoubled 的副作用都会自动重新执行

二、Signals如何解决现有痛点?

1. 跨框架的状态共享

这是Signals最诱人的特性之一。目前,React的状态、Vue的状态、Solid的状态互不兼容,无法直接共享。如果Signals成为语言标准,你写出的状态管理逻辑将可以在任何框架中使用

// store.js - 纯原生Signals,不依赖任何框架
const user = new Signal.State({ name: '张三', age: 28 });
const userInfo = new Signal.Computed(() => `${user.get().name}${user.get().age}岁`);

export { user, userInfo };
// React组件中使用
import { userInfo } from './store';

function UserCard() {
  // 通过适配器(未来框架可能内置)将Signal转换为React状态
  const info = useSignal(userInfo);
  return <div>{info}</div>;
}
<!-- Vue组件中使用 -->
<template>
  <div>{{ info }}</div>
</template>

<script setup>
import { userInfo } from './store';
// 未来Vue可直接支持原生Signal
const info = userInfo; // 自动解包响应式
</script>

2. 细粒度更新与性能

React Hooks的重新渲染是以组件为单位的,即使只有一小部分状态变化,整个组件函数都会重新执行。而Signals天然支持细粒度更新

// 使用Signals的组件
function Counter() {
  const count = new Signal.State(0);
  
  // 只有这部分依赖于count的代码会在count变化时重新执行
  const display = () => <span>{count.get()}</span>;
  
  // 按钮点击事件不会导致整个组件重新渲染
  const handleClick = () => count.set(count.get() + 1);
  
  return (
    <div>
      {display()}  {/* 仅此处更新 */}
      <button onClick={handleClick}>+</button>
    </div>
  );
}

在SolidJS等基于Signals的框架中,这种细粒度更新是默认行为,带来了极致的性能表现。

3. 消除依赖数组

React的useEffectuseMemouseCallback需要手动指定依赖数组,稍有不慎就会导致bug。Signals通过自动依赖追踪彻底解决了这个问题:

// 无需手动指定依赖
effect(() => {
  console.log(`Count changed to: ${count.get()}`);
}); // 自动追踪count

// 对比React
useEffect(() => {
  console.log(`Count changed to: ${count}`);
}, [count]); // 必须手动维护依赖

三、Signals的内部工作原理

依赖追踪

Signals通过一个全局的“当前执行上下文”来实现自动依赖追踪。当一个Computed或effect运行时,它会:

  1. 设置一个全局标志,表示“我正在计算”
  2. 执行用户提供的函数
  3. 在函数执行过程中,每次调用signal.get()时,都会记录当前正在执行的上下文与这个Signal的依赖关系
  4. 执行结束后,依赖图构建完成

更新传播

当一个Signal的值改变时:

  • 所有直接依赖它的Computed被标记为“脏”
  • 这些Computed会在下一次被读取时重新计算
  • effect会异步批量执行,避免重复计算

避免Glitches

Signals采用Push-Pull混合模型:值的变化通过Push(推送)触发依赖标记,但实际计算通过Pull(拉取)在需要时进行。这种模型能有效避免“菱形依赖”导致的中间状态不一致问题。


四、Signals vs 其他响应式方案

特性 原生Signals React Hooks Vue ref/computed SolidJS Signals
跨框架复用 ✅ 原生支持 ❌ 无法跨框架 ❌ 无法跨框架 ❌ 绑定框架
依赖追踪 自动 手动依赖数组 编译时自动 自动
细粒度更新 ❌ 组件级
学习成本
性能 极高 中等 极高

五、为什么现在关注Signals?

1. 框架正在先行采用Signals模式

虽然原生Signals还在Stage 1,但Signals模式已经在多个主流框架中落地:

框架 实现方式 现状
SolidJS 原生Signals 全框架基于Signals,细粒度响应式标杆
Angular Angular Signals v16引入,v17成为推荐响应式方案
Preact Preact Signals 独立库,可脱离Preact使用
Qwik Qwik Signals 细粒度响应式,支持惰性加载
Vue ref/computed 设计理念与Signals高度相似
Svelte stores 通过编译器实现类似能力

这些框架的实践证明了Signals模式的可行性和优越性。一旦原生Signals落地,这些框架的底层完全可以替换为原生实现,从而获得更好的性能和跨框架互通性。

2. 社区工具链正在适配

许多状态管理库已经开始拥抱Signals模式:

  • MobX:虽然比Signals更强大,但其核心思想与Signals一致
  • Redux Toolkit:引入了createActioncreateReducer,但尚未完全转向Signals
  • Zustand:轻量级状态管理,可以很容易地基于Signals构建

3. TypeScript支持

TypeScript团队已经表示会全力支持Signals提案,提供完善的类型推断。目前已有实验性的类型定义可供使用。

六、现在可以做什么?

虽然原生Signals还不能用于生产环境,但我们可以:

1. 学习Signals思想

尝试使用现有框架中的Signals实现(如SolidJS、Preact Signals),理解响应式编程的最佳实践。

2. 关注提案进展

Star并关注TC39的proposal-signals仓库,参与讨论。

3. 使用polyfill

可以通过@preact/signals-coresolid-js提前体验Signals的开发体验。

npm install @preact/signals-core
import { signal, computed, effect } from '@preact/signals-core';

const count = signal(0);
const doubled = computed(() => count.value * 2);

effect(() => {
  console.log(`Count: ${count.value}, doubled: ${doubled.value}`);
});

count.value = 1; // 自动触发effect

结语

Signals不仅仅是另一个JavaScript特性,它代表了响应式编程从“框架专属”到“语言原生”的范式转变。虽然目前它还处于早期阶段,但众多现代框架的先行实践已经证明了Signals模式的巨大价值。

正如当年Promise统一了异步编程、Proxy统一了响应式元编程一样,Signals有潜力统一前端状态管理。无论你是框架作者、库开发者还是普通应用开发者,了解Signals都将帮助你更好地把握JavaScript的未来发展方向。

最后提醒:原生Signals目前仍处于Stage 1,API和语义都可能发生变化,切勿在生产环境中直接使用。但学习Signals思想,关注提案进展,将为你在下一个技术浪潮中抢占先机。


更多开发技巧、前沿资讯,欢迎关注我的微信公众号【编程智匠】。在这里,我会定期分享实战经验,帮你少走弯路,用技术创造价值。

❌
❌