阅读视图

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

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

前言

在 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 套源码全公开,项目直接用

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

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-路由监听 / 跳转 / 守卫全攻略(附实战代码)

前言

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 组件。

Tauri v2 实战代码示例

执行摘要

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 搭了一个最小可扩展架构

我把本地 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子进程批量校验钱包,我踩了这些性能与安全的坑

背景

上个月,我接了一个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-前端工程化搭建

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盒子设置百分比读取的宽高没有撑开盒子解决方案

问题背景

当使用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

问题原因

在浏览器渲染过程 html解析->CSS解析->layout->paint->分块->光栅化->画

常规来讲给定固定px像素的时候,在layout时期dom的宽高就固定了,这时候就是触发vue的onMounted周期

而百分比因为不固定,这时候的dom元素宽高是默认宽高,需要根据父盒子去进行计算,从而导致这时候拿到的宽高并不是能够撑满盒子的宽高

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

不知道你有没有这种感觉:让 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 完整开发流程

目录

  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类

去除图片水印 (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 开发全栈项目

本文介绍如何用 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:让你的服务器组件告别“重复劳动”

一句话总结:它是专为 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中的坐标系及其转换

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 协议与数据流设计

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 插件深度解析(二):消息处理系统架构

长轮询、入站/出站消息、斜杠命令

消息处理是即时通讯插件的核心能力。本文将深入剖析 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去验...

电影截屏

引言

哈喽大家好,我是亿元程序员,相信大家都看或者听到过下面几句台词:

“我要验牌(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用得最溜的,未必是我最想要的工程师

这两周,我面试了 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,响应式编程的下一个十年

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思想,关注提案进展,将为你在下一个技术浪潮中抢占先机。


更多开发技巧、前沿资讯,欢迎关注我的微信公众号【编程智匠】。在这里,我会定期分享实战经验,帮你少走弯路,用技术创造价值。

❌