普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月3日首页

Next.js第四课 - 链接与导航

2026年4月3日 11:41

上节我们学习了布局和页面的使用,本节来聊聊链接和导航。在 Web 应用中,页面之间的跳转是最基本也是最重要的功能之一。Next.js 提供了强大的导航系统,不仅使用简单,而且性能优化做得非常好。

Link 组件

Link 组件是 Next.js 中进行导航的主要方式,它扩展了 HTML 的 <a> 标签,提供客户端导航功能。很多初学者会问,为什么不直接用 <a> 标签呢?其实 <Link> 组件在底层做了很多优化,比如预加载、客户端导航等,能让页面切换更加流畅。

基本用法

使用 Link 组件非常简单,只需要导入并使用它:

import Link from 'next/link'

export default function Navigation() {
  return (
    <nav>
      <Link href="/">首页</Link>
      <Link href="/about">关于</Link>
      <Link href="/contact">联系</Link>
    </nav>
  )
}

动态链接

在实际开发中,我们经常需要根据数据动态生成链接,比如文章列表、产品列表等:

import Link from 'next/link'

export default function PostList({ posts }: { posts: Post[] }) {
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <Link href={`/posts/${post.slug}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  )
}

链接属性

Link 组件支持很多有用的属性,可以满足各种导航需求:

import Link from 'next/link'

export default function Links() {
  return (
    <>
      {/* 新标签页打开 */}
      <Link href="/about" target="_blank" rel="noopener noreferrer">
        关于(新窗口)
      </Link>

      {/* 替换当前历史记录 */}
      <Link href="/dashboard" replace>
        仪表盘
      </Link>

      {/* 滚动到特定位置 */}
      <Link href="/about#team" scroll={false}>
        关于(不滚动)
      </Link>

      {/* 自定义类名 */}
      <Link
        href="/contact"
        className="nav-link"
        activeClassName="active"
      >
        联系
      </Link>
    </>
  )
}

链接到动态段

如果需要链接到动态路由,可以使用对象形式:

import Link from 'next/link'

export default function ProductList() {
  return (
    <Link
      href={{
        pathname: '/products/[id]',
        query: { id: '123' },
      }}
    >
      产品详情
    </Link>
  )
}

编程式导航

除了使用 Link 组件,有时候我们需要在代码中控制导航,比如点击按钮后跳转、表单提交后跳转等。这时候就需要用到编程式导航。

useRouter Hook

useRouter Hook 提供了编程式导航的方法。需要注意的是,这个 Hook 只能在客户端组件中使用,记得在文件顶部加上 'use client'

'use client'

import { useRouter } from 'next/navigation'

export default function NavigationButtons() {
  const router = useRouter()

  return (
    <div>
      <button onClick={() => router.push('/dashboard')}>
        前往仪表盘
      </button>

      <button onClick={() => router.replace('/login')}>
        替换到登录页
      </button>

      <button onClick={() => router.back()}>
        返回
      </button>

      <button onClick={() => router.forward()}>
        前进
      </button>

      <button onClick={() => router.refresh()}>
        刷新
      </button>
    </div>
  )
}

带 URL 参数的导航

有时候我们需要在导航时携带查询参数,比如搜索关键词、分页信息等:

'use client'

import { useRouter, useSearchParams } from 'next/navigation'

export default function SearchNavigation() {
  const router = useRouter()
  const searchParams = useSearchParams()

  function navigateWithParams() {
    const params = new URLSearchParams(searchParams)
    params.set('page', '2')
    params.set('sort', 'asc')

    router.push(`/search?${params.toString()}`)
  }

  return <button onClick={navigateWithParams}>搜索</button>
}

导航后回调

很多初学者会问,怎么在导航后执行一些操作?需要注意的是,router.push 是异步的,但它不返回 Promise,所以不能直接用 await。如果需要在导航后执行操作,可以使用 useEffect 监听路径变化。

'use client'

import { useRouter } from 'next/navigation'

export default function NavigationWithCallback() {
  const router = useRouter()

  function navigateWithCallback() {
    router.push('/dashboard')
    // 注意:push 是异步的,但没有 Promise
    // 如需导航后执行操作,使用 useEffect 监听
  }

  return <button onClick={navigateWithCallback}>导航</button>
}

路由 Hooks

Next.js 提供了一些实用的路由 Hooks,能帮助我们获取路由信息。

usePathname

获取当前路径名,这个在实现高亮菜单、面包屑导航等场景中很有用:

'use client'

import { usePathname } from 'next/navigation'
import Link from 'next/link'

export default function ActiveLink({
  href,
  children,
}: {
  href: string
  children: React.ReactNode
}) {
  const pathname = usePathname()
  const isActive = pathname === href

  return (
    <Link
      href={href}
      className={isActive ? 'text-blue-500 font-bold' : 'text-gray-700'}
    >
      {children}
    </Link>
  )
}

useSearchParams

获取当前 URL 的查询参数,常用于读取搜索关键词、分页信息等:

'use client'

import { useSearchParams } from 'next/navigation'

export default function ProductList() {
  const searchParams = useSearchParams()
  const page = searchParams.get('page') || '1'
  const sort = searchParams.get('sort') || 'desc'

  return (
    <div>
      <p>当前页: {page}</p>
      <p>排序: {sort}</p>
    </div>
  )
}

useParams

获取动态路由参数,比如文章的 slug、产品的 ID 等:

'use client'

import { useParams } from 'next/navigation'

export default function PostPage() {
  const params = useParams()
  const slug = params.slug as string

  return (
    <div>
      <h1>文章: {slug}</h1>
    </div>
  )
}

导航模式

下面分享几个在实际项目中常用的导航模式。

1. 面包屑导航

面包屑导航能让用户清楚自己当前所处的位置:

'use client'

import Link from 'next/link'
import { usePathname } from 'next/navigation'

export default function Breadcrumbs() {
  const pathname = usePathname()
  const segments = pathname.split('/').filter(Boolean)

  return (
    <nav className="flex gap-2">
      <Link href="/">首页</Link>
      {segments.map((segment, index) => {
        const href = '/' + segments.slice(0, index + 1).join('/')
        return (
          <span key={href}>
            <span className="mx-2">/</span>
            <Link href={href}>{segment}</Link>
          </span>
        )
      })}
    </nav>
  )
}

2. 标签页导航

使用查询参数实现标签页切换是一个常见且实用的模式:

'use client'

import { useSearchParams } from 'next/navigation'
import Link from 'next/link'

export default function Tabs() {
  const searchParams = useSearchParams()
  const tab = searchParams.get('tab') || 'overview'

  const tabs = [
    { id: 'overview', label: '概览' },
    { id: 'settings', label: '设置' },
    { id: 'analytics', label: '分析' },
  ]

  return (
    <div>
      <div className="flex gap-4 border-b">
        {tabs.map((t) => (
          <Link
            key={t.id}
            href={`?tab=${t.id}`}
            className={`pb-2 px-4 ${
              tab === t.id
                ? 'border-b-2 border-blue-500 text-blue-500'
                : 'text-gray-600'
            }`}
          >
            {t.label}
          </Link>
        ))}
      </div>
      {/* 标签页内容 */}
    </div>
  )
}

3. 分页导航

分页是列表页面必备的功能:

'use client'

import Link from 'next/link'
import { useSearchParams } from 'next/navigation'

export default function Pagination({ totalPages }: { totalPages: number }) {
  const searchParams = useSearchParams()
  const currentPage = Number(searchParams.get('page')) || 1

  return (
    <div className="flex gap-2">
      {currentPage > 1 && (
        <Link href={`?page=${currentPage - 1}`}>上一页</Link>
      )}

      {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
        <Link
          key={page}
          href={`?page=${page}`}
          className={
            currentPage === page ? 'font-bold text-blue-500' : ''
          }
        >
          {page}
        </Link>
      ))}

      {currentPage < totalPages && (
        <Link href={`?page=${currentPage + 1}`}>下一页</Link>
      )}
    </div>
  )
}

4. 模态框导航(拦截路由)

拦截路由是 Next.js 的一个高级特性,可以实现模态框效果:

'use client'

import { useRouter } from 'next/navigation'
import { useState } from 'react'

export default function ModalExample() {
  const router = useRouter()
  const [isOpen, setIsOpen] = useState(false)

  function openModal(photoId: string) {
    // 拦截导航,显示模态框
    setIsOpen(true)
    router.push(`/photos/${photoId}`)
  }

  function closeModal() {
    setIsOpen(false)
    router.back()
  }

  return (
    <>
      <button onClick={() => openModal('123')}>查看照片</button>
      {isOpen && (
        <div className="modal">
          <button onClick={closeModal}>关闭</button>
          <div>照片内容</div>
        </div>
      )}
    </>
  )
}

客户端导航详解

Next.js 的客户端导航是一个非常巧妙的设计,它的工作原理如下:

  1. 拦截 <a> 标签点击事件
  2. 使用 fetch 请求新页面的 JSON 数据
  3. 客户端渲染新页面
  4. 更新 URL 和浏览器历史记录

这样做的好处是:

  • 更快的页面切换 - 不需要完全重新加载页面
  • 保持状态 - 组件状态得以保留
  • 更好的用户体验 - 无缝的导航体验

如果需要禁用客户端导航,可以这样:

import Link from 'next/link'

// 禁用 prefetch
<Link href="/about" prefetch={false}>
  关于
</Link>

// 完全禁用客户端导航
<Link href="/external" scroll={false}>
  外部链接
</Link>

导航过渡效果

页面切换动画

使用 React 的 useTransition 可以实现页面切换时的加载状态:

'use client'

import { useRouter } from 'next/navigation'
import { useState, useTransition } from 'react'

export default function NavigationWithTransition() {
  const router = useRouter()
  const [isPending, startTransition] = useTransition()

  function handleClick(path: string) {
    startTransition(() => {
      router.push(path)
    })
  }

  return (
    <nav>
      <button
        onClick={() => handleClick('/dashboard')}
        disabled={isPending}
      >
        {isPending ? '加载中...' : '仪表盘'}
      </button>
    </nav>
  )
}

使用 Motion 动画

如果你使用 framer-motion 这样的动画库,可以给导航添加流畅的过渡效果:

'use client'

import { motion } from 'framer-motion'
import Link from 'next/link'
import { usePathname } from 'next/navigation'

export default function AnimatedNav() {
  const pathname = usePathname()

  const links = [
    { href: '/', label: '首页' },
    { href: '/about', label: '关于' },
    { href: '/contact', label: '联系' },
  ]

  return (
    <nav>
      {links.map((link) => (
        <Link key={link.href} href={link.href}>
          <motion.span
            className={pathname === link.href ? 'active' : ''}
            whileHover={{ scale: 1.1 }}
            whileTap={{ scale: 0.95 }}
          >
            {link.label}
          </motion.span>
        </Link>
      ))}
    </nav>
  )
}

导航守卫

重定向

很多时候我们需要在用户访问某个页面前先检查一些条件,比如是否登录、是否有权限等。这时候可以使用中间件来实现重定向:

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  // 检查认证
  const token = request.cookies.get('token')?.value

  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/dashboard/:path*', '/admin/:path*'],
}

客户端守卫

在客户端也可以实现路由守卫:

'use client'

import { useRouter } from 'next/navigation'
import { useEffect } from 'react'

export default function ProtectedRoute() {
  const router = useRouter()

  useEffect(() => {
    const isAuthenticated = checkAuth()

    if (!isAuthenticated) {
      router.push('/login')
    }
  }, [router])

  return <div>受保护的内容</div>
}

实用建议

这里分享几个在日常开发中特别实用的技巧。

优先使用 Link 组件

实际开发中,声明式导航通常比命令式导航更清晰,所以我建议优先使用 Link 组件:

// 推荐这样做
<Link href="/about">关于</Link>

// 传统的写法也可以,但 Link 更好
<a href="/about">关于</a>

为动态链接添加密钥

这个小细节很容易忽略——渲染动态链接列表时,记得给每个 Link 添加 key:

{posts.map((post) => (
  <Link key={post.id} href={`/posts/${post.slug}`}>
    {post.title}
  </Link>
))}

处理加载状态

这个技巧特别有用——给用户明确的加载反馈能大大提升用户体验:

'use client'

import { useRouter, usePathname } from 'next/navigation'
import { useState, useEffect } from 'react'

export default function LoadingIndicator() {
  const router = useRouter()
  const pathname = usePathname()
  const [isLoading, setIsLoading] = useState(false)

  useEffect(() => {
    const handleStart = () => setIsLoading(true)
    const handleComplete = () => setIsLoading(false)

    router.events?.on('routeChangeStart', handleStart)
    router.events?.on('routeChangeComplete', handleComplete)

    return () => {
      router.events?.off('routeChangeStart', handleStart)
      router.events?.off('routeChangeComplete', handleComplete)
    }
  }, [router])

  return isLoading ? <div>加载中...</div> : null
}

总结

本节我们详细学习了 Next.js 的链接和导航系统,包括 Link 组件、编程式导航、路由 Hooks、各种导航模式等。掌握好这些知识,你就能构建出导航流畅、用户体验优秀的应用了。

如果你对本节内容有任何疑问,欢迎在评论区提出来,我们一起学习讨论。

原文地址:https://blog.uuhb.cn/archives/Next-js-04.html

昨天 — 2026年4月2日首页

Next.js第二课 - 项目结构详解 - 优栈

2026年4月1日 19:00

上节我们搭建好了 Next.js 开发环境,本节就来详细了解一下 Next.js 的项目结构。很多初学者刚打开项目时会看到一堆文件和文件夹,不知道每个都是干什么的。别担心,本节会带你理清这些目录和文件的用途,让你对项目结构有一个清晰的认识。

项目结构概览

my-nextjs-app/
├── app/                          # App Router(主要工作目录)
│   ├── (auth)/                   # 路由组(不影响 URL)
│   │   ├── login/
│   │   │   └── page.tsx
│   │   └── register/
│   │       └── page.tsx
│   ├── api/                      # API 路由
│   │   └── users/
│   │       └── route.ts
│   ├── blog/                     # 应用路由
│   │   ├── [slug]/              # 动态路由
│   │   │   └── page.tsx
│   │   └── page.tsx
│   ├── layout.tsx               # 根布局
│   ├── page.tsx                 # 首页
│   ├── loading.tsx              # 加载状态
│   ├── error.tsx                # 错误处理
│   ├── not-found.tsx            # 404 页面
│   └── globals.css              # 全局样式
├── components/                   # 共享组件
│   ├── ui/                      # UI 基础组件
│   │   ├── Button.tsx
│   │   ├── Input.tsx
│   │   └── Card.tsx
│   └── layout/                  # 布局组件
│       ├── Header.tsx
│       └── Footer.tsx
├── lib/                         # 工具函数
│   ├── utils.ts
│   ├── api.ts
│   └── constants.ts
├── hooks/                       # 自定义 Hooks
│   ├── useAuth.ts
│   └── useData.ts
├── types/                       # TypeScript 类型
│   └── index.ts
├── public/                      # 静态资源
│   ├── images/
│   ├── fonts/
│   └── favicon.ico
├── styles/                      # 样式文件(可选)
│   └── globals.css
├── .env.local                   # 环境变量
├── .eslintrc.json              # ESLint 配置
├── .gitignore                  # Git 忽略文件
├── next.config.js              # Next.js 配置
├── package.json                # 项目配置
├── tsconfig.json               # TypeScript 配置
└── README.md                   # 项目说明

image.png

核心目录详解

1. app/ 目录 - App Router

app/ 目录是 Next.js 13+ 推荐的新路由系统,基于 React Server Components 构建。这是你工作中最常打交道的目录,绝大部分页面和路由都会放在这里。

特殊文件

文件 用途 必需
layout.tsx 定义布局和 UI 可选
page.tsx 定义路由的独特 UI 必需(可访问路由)
loading.tsx 加载时的 UI 替换 可选
error.tsx 错误边界 UI 可选
not-found.tsx 404 页面 可选
route.ts API 端点 API 路由必需

示例结构

app/
├── (marketing)/              # 路由组
│   ├── about/
│   │   └── page.tsx         # /about
│   ├── layout.tsx           # 营销页面共享布局
│   └── page.tsx             # /
├── (shop)/                   # 另一个路由组
│   ├── account/
│   │   └── page.tsx         # /account
│   └── layout.tsx           # 商店页面共享布局
├── products/
│   ├── [id]/                # 动态段
│   │   └── page.tsx         # /products/123
│   └── page.tsx             # /products
├── api/
│   └── users/
│       └── route.ts         # /api/users (API)
├── layout.tsx               # 根布局(所有页面共享)
└── page.tsx                 # 首页

2. components/ 目录

这里存放可复用的 React 组件。当你发现一段 UI 代码在多个页面重复出现时,就可以把它抽取成一个组件放到这里。随着项目变大,良好的组件组织会让代码更容易维护。

components/
├── ui/                       # 基础 UI 组件
│   ├── Button.tsx
│   ├── Input.tsx
│   ├── Modal.tsx
│   └── Table.tsx
├── layout/                   # 布局组件
│   ├── Header.tsx
│   ├── Footer.tsx
│   ├── Sidebar.tsx
│   └── Navigation.tsx
├── features/                 # 功能组件
│   ├── UserProfile.tsx
│   ├── ProductCard.tsx
│   └── CommentList.tsx
└── forms/                    # 表单组件
    ├── LoginForm.tsx
    └── ContactForm.tsx

组件示例

// components/ui/Button.tsx
interface ButtonProps {
  children: React.ReactNode
  onClick?: () => void
  variant?: 'primary' | 'secondary'
}

export function Button({ children, onClick, variant = 'primary' }: ButtonProps) {
  return (
    <button
      onClick={onClick}
      className={`px-4 py-2 rounded ${
        variant === 'primary'
          ? 'bg-blue-500 text-white'
          : 'bg-gray-200 text-gray-800'
      }`}
    >
      {children}
    </button>
  )
}

3. lib/ 目录

这里存放工具函数、API 客户端、常量等辅助代码。把不属于任何特定业务逻辑的通用代码放在这里是个好习惯。

lib/
├── utils/                    # 工具函数
│   ├── format.ts            # 格式化函数
│   ├── validation.ts        # 验证函数
│   └── helpers.ts           # 辅助函数
├── api/                      # API 客户端
│   ├── client.ts
│   ├── users.ts
│   └── products.ts
├── db/                       # 数据库相关
│   ├── connect.ts
│   └── queries.ts
└── constants.ts              # 常量定义

工具函数示例

// lib/utils/format.ts

export function formatDate(date: Date): string {
  return new Intl.DateTimeFormat('zh-CN', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  }).format(date)
}

export function formatCurrency(amount: number): string {
  return new Intl.NumberFormat('zh-CN', {
    style: 'currency',
    currency: 'CNY',
  }).format(amount)
}

4. hooks/ 目录

存放自定义 React Hooks。如果你有一些状态逻辑需要在多个组件中复用,就可以封装成自定义 Hook 放在这里。

hooks/
├── useAuth.ts                # 认证相关
├── useData.ts                # 数据获取
├── useForm.ts                # 表单处理
└── useLocalStorage.ts        # 本地存储

Hook 示例

// hooks/useAuth.ts
'use client'

import { useState, useEffect } from 'react'

export function useAuth() {
  const [isAuthenticated, setIsAuthenticated] = useState(false)
  const [user, setUser] = useState(null)

  useEffect(() => {
    // 检查认证状态
    const token = localStorage.getItem('token')
    setIsAuthenticated(!!token)
  }, [])

  return { isAuthenticated, user }
}

5. public/ 目录

这里存放静态资源,比如图片、字体、favicon 等。放在 public 目录下的文件可以直接通过 URL 访问,不需要 import。

public/
├── images/
│   ├── logo.png
│   └── banner.jpg
├── fonts/
│   └── custom-font.woff2
├── favicon.ico
└── robots.txt

使用方式

// 在组件中引用
<Image src="/images/logo.png" alt="Logo" width={200} height={100} />

6. 根配置文件

next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,

  // 图片优化配置
  images: {
    domains: ['example.com'],
    formats: ['image/avif', 'image/webp'],
  },

  // 环境变量
  env: {
    CUSTOM_KEY: process.env.CUSTOM_KEY,
  },

  // 重定向
  async redirects() {
    return [
      {
        source: '/old-path',
        destination: '/new-path',
        permanent: true,
      },
    ]
  },
}

module.exports = nextConfig

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

路由组织最佳实践

1. 使用路由组

路由组 (group-name) 是 Next.js 的一个很有用的特性,它不会影响 URL 路径,但可以帮助你更好地组织代码和共享布局。比如你想把某些页面放在一起管理,但又不想改变 URL 结构,就可以用路由组。

app/
├── (marketing)/              # /about, /contact
│   ├── about/
│   │   └── page.tsx
│   ├── contact/
│   │   └── page.tsx
│   └── layout.tsx           # 营销页面共享布局
├── (dashboard)/              # /dashboard, /settings
│   ├── dashboard/
│   │   └── page.tsx
│   ├── settings/
│   │   └── page.tsx
│   └── layout.tsx           # 需要认证的布局
└── page.tsx                 # 首页

2. 动态路由

动态路由在实际开发中非常常见,比如博客文章页、用户详情页等。使用方括号 [param] 就可以创建动态路由段,Next.js 会自动匹配并解析参数。

app/
├── blog/
│   ├── [slug]/              # /blog/hello-world
│   │   └── page.tsx
│   └── page.tsx             # /blog
├── products/
│   ├── [category]/          # /products/electronics
│   │   └── page.tsx
│   └── page.tsx             # /products
└── users/
    └── [id]/                # /users/123
        ├── [action]/        # /users/123/edit
        │   └── page.tsx
        └── page.tsx         # /users/123

3. 并行和拦截路由

并行路由和拦截路由是 Next.js 的高级特性,可以实现一些复杂的交互效果,比如模态框、并行加载多个页面等。这些特性在实际项目中非常有用,但理解起来可能需要一点时间。

app/
├── @dashboard/               # 并行路由槽
│   └── page.tsx
├── (.)modal/                 # 拦截路由
│   └── photo/[id]/page.tsx
├── dashboard/
│   └── page.tsx
└── layout.tsx

文件命名约定

路由相关

模式 说明 示例 URL
folder/page.tsx 标准路由 /folder
folder/[slug]/page.tsx 动态路由 /folder/value
folder/[[...slug]]/page.tsx 捕获所有路由 /folder/a/b/c
(group)/page.tsx 路由组 /page
folder/(.)modal/... 拦截路由 -

特殊文件

文件 说明
_filename.tsx 私有文件,不创建路由
filename.server.tsx 仅在服务器运行
filename.client.tsx 仅在客户端运行

代码组织建议

1. 按功能组织

按功能组织是一种常见的项目结构方式,把相关的功能放在一起。这种方式适合中小型项目,代码结构清晰易懂。

app/
├── (auth)/
│   ├── login/
│   ├── register/
│   └── forgot-password/
├── (dashboard)/
│   ├── overview/
│   ├── analytics/
│   └── settings/
└── (public)/
    ├── about/
    ├── contact/
    └── pricing/

2. 按层级组织

按层级组织适合大型项目,比如有 API 版本管理、多级管理后台等场景。这种方式可以让结构更有层次感。

app/
├── api/
│   ├── v1/
│   │   ├── users/
│   │   └── posts/
│   └── v2/
│       └── users/
└── admin/
    └── users/
        ├── [id]/
        └── new/

3. 组件分层

组件分层是一种借鉴原子设计的组织方式,把组件按照复杂度分成原子、分子、组织、模板等层级。这种方式适合 UI 组件库或者设计系统比较完善的项目。

components/
├── atoms/                    # 最小单元
│   ├── Button.tsx
│   └── Input.tsx
├── molecules/                # 组合原子
│   ├── SearchBar.tsx
│   └── FormField.tsx
├── organisms/                # 复杂组件
│   ├── Header.tsx
│   └── ProductCard.tsx
└── templates/                # 页面模板
    └── BlogLayout.tsx

环境变量

环境变量用来存储一些敏感信息或者配置,比如数据库连接字符串、API 密钥等。创建 .env.local 文件来存放这些信息,记得把这个文件加到 .gitignore 里,不要提交到代码仓库。

# 数据库
DATABASE_URL=postgresql://...

# API 密钥
API_KEY=your_api_key
API_SECRET=your_api_secret

# 应用配置
NEXT_PUBLIC_APP_URL=http://localhost:3000

访问方式:

// 服务器端
const dbUrl = process.env.DATABASE_URL

// 客户端(必须以 NEXT_PUBLIC_ 开头)
const appUrl = process.env.NEXT_PUBLIC_APP_URL

总结

本节我们详细了解了 Next.js 的项目结构,包括各个目录的用途、路由组织方式、以及一些最佳实践。掌握项目结构是学好 Next.js 的基础,建议你多花点时间理解这些内容。

如果你对本节内容有任何疑问,欢迎在评论区提出来,我们一起学习讨论。

原文链接:https://blog.uuhb.cn/archives/Next-js-02.html

昨天以前首页

Next.js全栈项目部署全流程|从0到1解决数据库、WebSocket、图片上传所有坑

作者 前端缘梦
2026年3月31日 23:14

为一名前端开发者,终于完整开发并部署了一个Next.js全栈项目,涵盖用户登录注册、商品发布、实时聊天等核心功能。部署过程中踩了无数坑,从数据库连接失败到WebSocket频繁断开,从图片上传报错到环境变量不生效,每一个问题都花了不少时间排查。

今天就把整个部署流程、用到的所有工具、遇到的问题及解决方案,完整梳理出来,希望能帮到正在做Next.js全栈部署的小伙伴,少走弯路,一次上线成功!

一、项目基础信息(先明确核心技术栈)

先交代下项目的核心技术栈,方便大家对应自己的项目场景参考:

  • 前端框架:Next.js 16(App Router)+ TypeScript
  • UI样式:Tailwind CSS
  • 数据库:MySQL(免费在线实例)
  • ORM工具:Prisma(连接、操作数据库)
  • 部署平台:Vercel(Next.js官方推荐,零配置部署)
  • 图片存储:Vercel Blob(解决Vercel只读文件系统问题)
  • 实时聊天:Pusher(第三方WebSocket服务,避免自建服务器)
  • 代码托管:GitHub(版本管理+Vercel自动拉取部署)

项目核心功能:用户登录注册、商品发布/展示、地址管理、一对一实时聊天,是一个完整的小型全栈应用,适合作为简历项目或练手案例。

二、用到的所有网站/工具(全程实战可用)

整个部署过程没有用到复杂的服务器,全部依赖免费工具/网站,新手也能轻松上手,整理了一张清单,一目了然:

工具/网站 核心作用 解决的问题
GitHub 代码托管、版本管理 存放项目代码,供Vercel自动拉取部署
Vercel 全栈项目部署、公网访问 实现项目一键上线,提供环境变量、日志排查功能
freesqldatabase.com 免费在线MySQL数据库 提供公网可访问的数据库实例,无需本地搭建
Prisma ORM工具,连接/操作数据库 无需写复杂SQL,自动建表、实现CRUD,防SQL注入
Vercel Blob 免费文件存储服务 解决Vercel只读文件系统,实现图片上传功能
Pusher 第三方实时消息服务 实现稳定WebSocket通信,解决聊天断开(code=1006)问题
phpMyAdmin 数据库可视化管理 查看数据库表结构、排查数据问题

三、完整部署流程(一步步跟着来,不踩坑)

部署全程遵循「代码准备 → 数据库配置 → 部署平台配置 → 功能适配 → 问题排查」的逻辑,每一步都对应实际操作,新手可直接照做。

第一步:代码准备与GitHub托管

  1. 本地完成Next.js全栈项目开发,确保核心功能(登录、商品、聊天)本地能正常运行。
  2. 在GitHub上新建仓库(比如命名为eslife-0929),将本地项目代码推送到GitHub仓库(注意忽略node_modules、.env等敏感文件)。
  3. 确认仓库代码完整,包含Prisma配置文件(schema.prisma)、API接口(app/api目录)、前端组件等核心文件。

第二步:数据库配置(MySQL + Prisma)

数据库是全栈项目的核心,这里用免费在线MySQL,无需本地搭建,步骤如下:

  1. 访问freesqldatabase.com,注册账号后,系统会自动创建一个MySQL数据库,获得核心连接信息:主机(host)、端口(port)、用户名(user)、密码(password)、数据库名(dbname)。

  2. 在本地项目根目录,配置Prisma:

    1. 修改schema.prisma文件,定义User、Commodity、Chat等数据表模型(对应项目功能)。
    2. 在.env.local文件中,配置数据库连接串:DATABASE_URL=mysql://user:password@host:port/dbname
  3. 本地执行prisma db push命令,Prisma会自动根据schema.prisma在远程MySQL数据库中生成对应的数据表。

  4. 通过phpMyAdmin登录数据库,确认数据表创建成功,确保本地能正常连接并操作数据库。

第三步:Vercel部署项目(核心步骤)

Vercel是Next.js官方部署平台,零配置、免费、自动CI/CD,是新手部署Next.js项目的首选,步骤如下:

  1. 访问Vercel官网,登录账号(建议用GitHub账号登录,方便关联仓库)。
  2. 点击「New Project」,导入GitHub上的项目仓库,Vercel会自动识别Next.js项目,无需手动配置构建命令。
  3. 配置环境变量:在Vercel项目后台,进入Settings → Environment Variables,添加项目所需的环境变量(重点是DATABASE_URL),并勾选Production、Preview、Development三个环境,确保环境变量全局生效。
  4. 点击「Deploy」,Vercel会自动拉取GitHub代码、安装依赖、构建项目,等待1-2分钟,部署完成后会生成一个公网可访问的域名(比如eslife-0929.vercel.app)。

第四步:功能适配(图片上传 + WebSocket)

部署完成后,会发现两个核心功能报错(图片上传、聊天),这是Vercel和免费WebSocket测试地址的限制,需要针对性适配:

1. 图片上传适配(解决Vercel只读文件系统)

Vercel的文件系统是只读的,无法将图片存到项目本地(会报EROFS: read-only file system错误),解决方案是使用Vercel Blob免费存储:

  1. 在Vercel项目后台,进入Storage → Create Store,选择Blob,创建一个存储实例(命名随意,比如eslife-images),选择Public模式(图片需要公网访问)。
  2. 创建完成后,Vercel会自动生成BLOB_READ_WRITE_TOKEN环境变量,无需手动创建。
  3. 本地项目安装@vercel/blob依赖(npm install @vercel/blob),修改图片上传API代码,替换成本地文件上传为Vercel Blob上传(代码见文末附录)。
  4. 将修改后的代码推送到GitHub,Vercel自动重新部署,图片上传功能正常。

2. WebSocket适配(解决聊天断开code=1006)

初期使用公共WebSocket测试地址(如wss://ws.postman-echo.com/raw),频繁断开报错(code=1006),原因是公共测试地址不支持持久聊天,解决方案是使用Pusher第三方实时服务:

  1. 访问Pusher官网,注册账号后,新建一个Channels应用,获得4个核心密钥:appId、key、secret、cluster。
  2. 在Vercel环境变量中,添加Pusher相关变量(NEXT_PUBLIC_PUSHER_KEY、NEXT_PUBLIC_PUSHER_CLUSTER、PUSHER_APP_ID、PUSHER_SECRET),勾选三个环境并保存。
  3. 修改前端聊天组件代码(初始化Pusher、订阅频道、监听消息)和后端发送消息API(触发Pusher消息推送),替换原来的WebSocket连接代码(代码见文末附录)。
  4. 推送代码到GitHub,Vercel自动部署,聊天功能稳定连接,不再出现断开报错。

第五步:测试验证

部署完成后,访问Vercel生成的公网域名,逐一测试核心功能:

  • 登录注册:正常访问,数据能写入数据库
  • 商品发布:能正常上传图片、保存商品信息
  • 实时聊天:能正常发送、接收消息,无断开报错
  • 地址管理:能正常新增、编辑、删除地址

所有功能正常,说明项目部署成功,可公网访问、正常使用。

四、部署过程中遇到的核心问题及解决方案(重点!)

这部分是重点,整理了我部署时遇到的5个核心问题,每个问题都有明确的报错、原因和解决方案,大家遇到相同问题可直接参考:

问题1:Prisma客户端未生成,部署失败(PrismaClientInitializationError)

  • 报错信息:Invalid prisma.user.findUnique() invocation: PrismaClientInitializationError
  • 原因:Vercel缓存依赖,构建时不会自动执行prisma generate,导致Prisma客户端未生成,无法连接数据库。
  • 解决方案:修改package.json的build命令,添加prisma generate,修改后为:"build": "prisma generate && next build",推送代码重新部署即可。

问题2:数据库连接失败,用户名被拒绝访问(%20xxx)

  • 报错信息:User 'sql12821786' was denied access on the database '%20sql12821786'
  • 原因:DATABASE_URL环境变量中,数据库名前面多了一个空格(%20是URL编码后的空格),导致连接失败。
  • 解决方案:重新粘贴无空格的DATABASE_URL连接串,确保前后无空格、无引号,保存后重新部署。

问题3:环境变量未生效(Environment variable not found: DATABASE_URL)

  • 报错信息:Environment variable not found: DATABASE_URL,Validation Error Count: 1
  • 原因:Vercel环境变量未勾选Production环境,导致线上部署时无法读取环境变量。
  • 解决方案:进入Vercel环境变量页面,勾选Production、Preview、Development三个环境,点击Save保存,重新部署。

问题4:图片上传报错(EROFS: read-only file system)

  • 报错信息:Error: EROFS: read-only file system, open '/var/task/public/uploads/xxx.png'
  • 原因:Vercel服务器文件系统是只读的,无法将图片写入本地public目录。
  • 解决方案:使用Vercel Blob云存储,替换图片上传逻辑,具体代码见附录。

问题5:WebSocket频繁断开(code=1006)

  • 报错信息:WebSocket 已断开(code=1006),请确认聊天服务已启动
  • 原因:使用的公共WebSocket测试地址(如postman-echo)是echo服务,不支持持久聊天,连接会自动断开。
  • 解决方案:接入Pusher第三方实时服务,替换WebSocket连接逻辑,具体代码见附录。

五、总结与感悟

从开发到部署,整个过程虽然踩了很多坑,但每解决一个问题,都对Next.js全栈部署有了更深入的理解。其实Next.js全栈部署并不复杂,核心是掌握「环境变量配置、数据库连接、第三方服务适配」这三个关键点。

本次部署全程使用免费工具,无需购买服务器,新手也能轻松上手,最终实现了项目公网访问,所有核心功能正常运行,这个项目也成为了我简历中的一个重要亮点。

最后,整理了两个核心功能的适配代码,大家可直接复制使用,避免重复踩坑。如果大家在部署过程中遇到其他问题,欢迎在评论区交流,一起解决!

附录:核心适配代码(直接复制可用)

1. Vercel Blob图片上传API(app/api/upload/route.ts)

import { put } from '@vercel/blob';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  try {
    const formData = await request.formData();
    const file = formData.get('file') as File;

    if (!file) {
      return NextResponse.json({ error: '请选择文件' }, { status: 400 });
    }

    // 上传到Vercel Blob,公开访问
    const blob = await put(file.name, file, {
      access: 'public',
    });

    // 返回图片URL,用于前端展示和数据库存储
    return NextResponse.json({
      url: blob.url,
    });
  } catch (error) {
    return NextResponse.json({ error: '上传失败' }, { status: 500 });
  }
}

2. Pusher实时聊天适配代码

前端聊天组件(components/ChatWindow.tsx)

import { useEffect, useState } from 'react';
import Pusher from 'pusher-js';

const ChatWindow = ({ userId, targetUserId }: { userId: number; targetUserId: number }) => {
  const [messages, setMessages] = useState<Array<{fromUserId: number, content: string, timestamp: string}>>([]);
  const [inputContent, setInputContent] = useState('');

  useEffect(() => {
    // 初始化Pusher
    if (!process.env.NEXT_PUBLIC_PUSHER_KEY || !process.env.NEXT_PUBLIC_PUSHER_CLUSTER) return;

    const pusher = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY, {
      cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER,
    });

    // 订阅私有聊天频道(两个用户ID组合,保证隐私)
    const channelName = `private-chat-${Math.min(userId, targetUserId)}-${Math.max(userId, targetUserId)}`;
    const channel = pusher.subscribe(channelName);

    // 监听聊天消息
    channel.bind('chat-message', (data) => {
      setMessages(prev => [...prev, data]);
    });

    // 组件卸载时清理连接
    return () => {
      channel.unbind_all();
      channel.unsubscribe();
      pusher.disconnect();
    };
  }, [userId, targetUserId]);

  // 发送消息
  const sendMessage = async () => {
    if (!inputContent.trim()) return;

    await fetch('/api/chat/send', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ fromUserId: userId, toUserId: targetUserId, content: inputContent }),
    });
    
    setInputContent('');
  };

  return (
    
        {messages.map((msg, index) => (
          <div 
            key={ ${
              msg.fromUserId === userId ? 'bg-blue-500 text-white self-end' : 'bg-gray-200 self-start'
            }`}
          >
           {msg.content}{new Date(msg.timestamp).toLocaleTimeString()}
        ))}
      <input
          type="text"
          value={ onChange={(e) => setInputContent(e.target.value)}
          placeholder="输入消息..."
          className="flex-1 px-3 py-2 border rounded"
        />
        <button onClick={发送
  );
};

export default ChatWindow;

后端发送消息API(app/api/chat/send/route.ts)

import { NextRequest, NextResponse } from 'next/server';
import Pusher from 'pusher';

// 初始化Pusher后端实例
const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID as string,
  key: process.env.PUSHER_KEY as string,
  secret: process.env.PUSHER_SECRET as string,
  cluster: process.env.PUSHER_CLUSTER as string,
  useTLS: true, // 开启加密连接,避免安全问题
});

export async function POST(request: NextRequest) {
  try {
    const { fromUserId, toUserId, content } = await request.json();

    // 定义聊天频道(与前端一致,保证消息能正确推送)
    const channelName = `private-chat-${Math.min(fromUserId, toUserId)}-${Math.max(fromUserId, toUserId)}`;
    const eventName = 'chat-message';

    // 触发消息推送,推送给订阅该频道的前端
    await pusher.trigger(channelName, eventName, {
      fromUserId,
      toUserId,
      content,
      timestamp: new Date().toISOString(),
    });

    return NextResponse.json({ success: true, message: '消息已发送' });
  } catch (error) {
    console.error('Pusher推送失败:', error);
    return NextResponse.json({ error: '发送失败' }, { status: 500 });
  }
}

🚀 开源发布!从 0 到 1,使用 Next.js + Nest.js 构建全栈自动化数据分析 AI Agent

2026年4月1日 08:50

还在发愁只会简单地调用 LLM API,不知道如何将 AI 技术落地到复杂的真实业务中吗?

今天和大家分享一个刚刚开源的完整硬核项目:AI Data Analyzer (自动化数据分析 AI Agent)!不仅开源了全部代码,还配有全网最详尽的「保姆级」全栈图文教程

💡 这是一个什么项目?

这是一个基于(Next.js + Nest.js)构建的全栈自动化数据分析平台。它结合了大语言模型(LLM),实现了从数据上传 ➡️ 自动化清洗 ➡️ 智能分析 ➡️ 可视化图表呈现的完整数据处理管道。它不是一个简单的玩具,而是按照工业级标准设计的企业级 AI 应用雏形!

🔥 核心亮点:

  • 🛠 最前沿的全栈技术栈:前端 Next.js (App Router) + React 19 + TailwindCSS v4;后端 NestJS + PostgreSQL + TypeORM。
  • 🤖 多模型自由切换:统一的 ILLMService 接口,零成本切换 OpenAI、Claude,甚至支持本地部署部署模型 (Ollama + Qwen)。
  • ⚡️ 极致的实时交互体验:结合 SSE (Server-Sent Events) 实现打字机流式输出,结合 WebSocket 实时推送 AI Agent 的底层思考与执行步骤!
  • ⏳ 工业级高并发设计:接入 Redis + BullMQ 构建异步任务队列,支持海量数据后台处理,主流程丝滑不阻塞。
  • 📊 酷炫的 2D/3D 进阶可视化:告别枯燥表格!集成 ECharts 和 Three.js (React Three Fiber),实现多维数据雷达图、AI 关系力导向图谱,甚至 3D 数据星系交互!
  • 🛡 严谨的数据与反馈机制:使用 Zod + class-validator 解决 AI 幻觉重试,并内置了人类反馈接口 (RLHF 基础) 打造数据飞轮。

📚 配套系列教程

只看源码太枯燥?没关系!项目配套了 《使用 Next.js 与 Nest.js 构建自动化数据分析 AI Agent》 系列长文教程。从架构设计、环境搭建、大模型对接,到前端可视化仪表盘、异步队列实战,手把手带你打通全栈 AI 链路!

image.png

image.png

🔗 传送门

欢迎大家 Star ⭐️ 关注、Fork 把玩,也期待提交 PR 一起完善!如果有任何问题,欢迎在评论区或 Issue 中交流讨论~ 👇👇👇

我是怎么把 Multi-Tool Runtime 升级成第一层 Skill Runtime 的

作者 倾颜
2026年4月1日 00:16

本文对应项目版本:v0.0.7

v0.0.6 里,我已经把项目从单 Tool Calling 推进到了 Multi-Tool Runtime:

  • calculator
  • datetime
  • text-transform
  • Tool Registry
  • 前端多 Tool 卡片展示
  • 最近 N=8 轮上下文窗口

走到这一步后,我发现项目开始进入另一个更像工程问题的阶段:

当系统里已经有多个 Tool 之后,下一步到底应该继续堆 Tool,还是先往上抽一层更稳定的能力封装?

这就是 v0.0.7 想回答的问题。

这一版我没有直接去做 Agent,也没有急着接 MCP,而是先做了一件更克制、但我认为更关键的事:

在现有的 Multi-Tool Runtime 之上,长出第一层 Skill Runtime。

这篇文章主要讲 4 件事:

  1. 为什么现在做 Skill 是合适的
  2. utility-skill 到底解决了什么问题
  3. Runtime 怎么接 Skill,而不是把它做成另一套散乱逻辑
  4. 为什么 Prompt 很重要,但版本主题仍然要保持克制

项目主界面截图

skill-2.png

Skill Runtime 链路图

用户输入
  -> /api/chat
    -> 读取 options.skill
      -> SkillRegistry 获取 skill 定义
        -> allowedTools 过滤 ToolRegistry
        -> 注入 skill.systemPrompt
          -> 模型 planning
            -> 选择 tool_call
              -> Runtime 校验/执行 tool
                -> tool result 回填
                  -> 最终回答流式返回前端

为什么 v0.0.7 不直接做 Agent

这件事必须先讲清楚。

因为从版本节奏上看,v0.0.6 做完之后,很容易产生一种冲动:

  • 既然已经有多个 Tool 了,是不是下一步就该直接做 Agent?
  • 是不是该把 Skill、MCP、记忆、任务规划一起拉进来?

我最后没有这么做,原因其实很简单:

  • v0.0.5 验证的是:单 Tool Calling 可不可行
  • v0.0.6 验证的是:Multi-Tool Runtime 能不能站住
  • 到了 v0.0.7,更值得验证的是:Tool 之上能不能再稳定长出一层能力模式

如果这时直接跳去做 Agent,会把很多变量混在一起:

  • Tool 选择是否稳定
  • Tool Runtime 是否清晰
  • Prompt 约束是否足够
  • 是否需要多步计划
  • 是否需要记忆系统
  • 是否需要外部能力接入

这些问题一旦一起出现,版本主题很容易被冲散。

所以我给 v0.0.7 定下的边界非常明确:

  1. 只落一个正式 Skill:utility-skill
  2. 新增一个 Tool:unit-convert
  3. 不做 Skill UI
  4. 不做自动 Skill 路由
  5. 不做 Agent Loop
  6. 不做 MCP 接入

一句话概括:

这一版不是为了证明“系统更聪明了”,而是为了验证:Tool Runtime 之上,能不能再稳定长出第一层 Skill Runtime。

这版到底解决了什么问题

v0.0.6 做完之后,项目已经有了多个 Tool,但也暴露了一个很真实的问题:

1. Tool 还是“散着的能力”

虽然已经有:

  • calculator
  • datetime
  • text-transform

但它们本质上还是一组分散的原子能力。系统并没有一层更高的语义去表达:

  • 当前是在什么任务模式下工作
  • 当前允许使用哪些 Tool
  • 当前回答应该更偏“结果优先”还是“解释优先”

2. 多 Tool 之后,Prompt 开始越来越重要

比如下面这些问题:

  • 357×28+999+1 等于多少
  • 今天是周几
  • 提取这段文本里的链接

按理说都应该优先走 Tool。但如果没有一层更清晰的能力模式约束,模型很容易出现这些行为:

  • 自己先口算
  • reasoning 里说“应该调用工具”,但没真正发起 tool_call
  • 输出风格越来越散

3. 我需要一层更高的“能力模式”

如果继续在 chat-service.ts 里堆更多 Tool 特判,最后只会让 Runtime 越来越重。

真正需要的,是一层更高的东西,让系统能够明确知道:

  • 当前属于哪类能力域
  • 当前允许哪些 Tool
  • 当前输出应该是什么风格

这个东西,就是这一版里的 Skill。

Skill 在这里到底是什么

我对 Skill 的理解,不是“另一个 Tool”,也不是“精简版 Agent”。

它更像是:

站在 Tool 之上的一层高阶能力模式。

v0.0.7 里,我刻意让 Skill 只承担很克制的职责:

  • 定义一个任务域
  • 提供一段 system prompt
  • 限制当前允许使用的 Tool 集
  • 约束输出策略

不直接执行 Tool,也不负责多步规划

这点很重要,因为我不想让 Skill 偷偷长成 Agent。

为什么是 utility-skill

第一版 Skill 我没有做 research-skill,也没有做 writer-skill,而是选了一个更克制的方向:

  • utility-skill

它对应的是一类非常具体的任务:

  • 精确计算
  • 时间与日期处理
  • 文本转换与提取
  • 单位换算

我选它主要有三个原因。

1. 它和当前 Tool 集天然衔接

v0.0.6 已经有:

  • calculator
  • datetime
  • text-transform

这些 Tool 天然就属于“日常实用任务”的一部分。

所以 utility-skill 不是硬造出来的抽象,而是从当前 Tool 集里自然长出来的。

2. 它足够轻,但足够证明 Skill 是成立的

它不需要:

  • 外部系统
  • MCP
  • 复杂记忆
  • Agent Loop

但它足够证明一件很重要的事:

Skill 不是一段 Prompt,而是一层真的会影响 Runtime 的能力定义。

3. 它能继续长,但不会把版本做散

如果第一版就做 research-skill,很快就会牵扯到:

  • 搜索
  • 抓取
  • 来源引用
  • 多步编排

utility-skill 足够小,刚好能帮我验证 Skill Runtime 的骨架,不会把版本主题拉散。

总体架构:在 Multi-Tool Runtime 之上再加一层 Skill

这一版的主链路长这样:

用户输入
  -> 前端页面
    -> /api/chat
      -> chat-service
        -> Skill Registry
        -> Tool Registry
        -> ChatOllama
          -> tool calling / tool execution
            -> NDJSON stream
              -> useChatStream
                -> reasoning / tool / text 渲染

Skill Runtime 总体链路图

flowchart LR
    A["用户请求"] --> B["/api/chat"]
    B --> C["命中 Skill"]
    C --> D["读取 SkillDefinition"]
    D --> E["限制可用 Tools"]
    E --> F["模型选择是否调用 Tool"]
    F --> G["Runtime 执行 Tool"]
    G --> H["组合最终结果"]
    H --> I["流式返回前端"]

如果用一句话概括,就是:

Tool 负责原子能力,Skill 负责高层能力模式,Runtime 负责把两者接起来。

这一版里最关键的变化不是“多了一个 unit-convert”,而是:

  • Runtime 先感知 Skill
  • 再决定当前可用 Tool 集
  • 再把这套能力边界交给模型

Tool 之上再加一层 Skill,最重要的是 Registry

如果想让 Skill 成为正式能力层,第一步就不能继续把相关逻辑散落在 chat-service.ts 里。

所以我先做的是:把 Skill 和 Tool 一样,也收进 Registry。

关键代码:Skill 的统一定义接口

这段代码的作用是:让 Skill 也成为一个可注册、可查询、可扩展的能力单元。

export type SkillOutputPolicy = 'concise-utility'
export type SkillResultPolicy = 'tool-first'

export interface SkillDefinition {
  name: string
  description: string
  systemPrompt: string
  allowedTools: string[]
  outputPolicy?: SkillOutputPolicy
  resultPolicy?: SkillResultPolicy
  routingHints?: string[]
  isAvailable?: () => boolean
}

这里我最看重的,不是 namesystemPrompt,而是下面这些字段:

  • allowedTools
  • outputPolicy
  • resultPolicy
  • routingHints

它们决定了 Skill 不是“模型的一段额外说明”,而是 Runtime 可以真正感知的一层能力配置。

关键代码:Skill Registry

这段代码的作用是:让 Runtime 只通过统一入口读取 Skill,而不是在主流程里到处判断具体 Skill 名称。

export interface ChatSkillRegistry {
  list(): SkillDefinition[]
  listActive(): SkillDefinition[]
  get(name: string): SkillDefinition | undefined
}

export function createChatSkillRegistry(skillDefinitions: SkillDefinition[]): ChatSkillRegistry {
  const skillDefinitionMap = new Map(
    skillDefinitions.map(skillDefinition => [skillDefinition.name, skillDefinition])
  )

  return {
    list() {
      return skillDefinitions
    },
    listActive() {
      return skillDefinitions.filter(skillDefinition => skillDefinition.isAvailable?.() ?? true)
    },
    get(name: string) {
      return skillDefinitionMap.get(name)
    },
  }
}

这一步其实是在为后面的演进打基础:

  • 今天是 utility-skill
  • 后面可以是 research-skill
  • 再往后甚至可以有 MCP-based skill

但 Runtime 主链不需要被这些具体名字污染。

utility-skill 是怎么定义的

这一版里,utility-skill 并没有做什么“神秘编排”,它做的事情非常务实:

  • 定义任务域
  • 指定允许使用的 Tool
  • 用 Prompt 约束输出风格

关键代码:utility-skill definition

这段代码的作用是:把“日常实用任务”正式定义成一层 Skill。

export const utilitySkillDefinition: SkillDefinition = {
  name: 'utility-skill',
  description: '处理日常确定性实用任务的稳定能力层',
  systemPrompt: `...`,
  allowedTools: ['calculator', 'datetime', 'text-transform', 'unit-convert'],
  outputPolicy: 'concise-utility',
  resultPolicy: 'tool-first',
  routingHints: [
    'math',
    'date',
    'time',
    'weekday',
    'relative-date',
    'convert',
    'markdown-to-text',
    'extract-links',
    'json-format',
    'unit-conversion',
  ],
}

这里最关键的有两个点。

1. allowedTools

这一版里 Skill 的价值不只是“多一段 Prompt”,而是它会真的影响当前 Runtime 的可用能力边界。

也就是说,在 utility-skill 下,当前允许给模型看到的 Tool,就是这四个:

  • calculator
  • datetime
  • text-transform
  • unit-convert

2. tool-first

我给 utility-skill 选的结果策略是:

  • tool-first

原因很简单,这类任务本来就是高度确定性的:

  • 计算
  • 日期
  • 单位换算

如果模型已经拿到了 Tool 结果,再让它自由发挥,反而更容易把答案写歪。

Runtime 怎么真正接 Skill

Skill 真正有价值的地方,不在 definition 文件,而在于 Runtime 会不会把它接进去。

关键代码:请求里读取 Skill

这段代码的作用是:让 Skill 成为正式请求参数,而不是隐含状态。

function resolveRequestedSkill(request: ChatRequest): SkillDefinition | undefined {
  const skillName = request.options?.skill?.trim()

  if (!skillName) {
    return undefined
  }

  const skillDefinition = getChatSkillDefinition(skillName)

  if (!skillDefinition || !(skillDefinition.isAvailable?.() ?? true)) {
    throw createInvalidSkillError(skillName)
  }

  return skillDefinition
}

这里我刻意让 skilloptions.skill 的方式显式传入,而不是让系统自动猜当前该用哪个 Skill。

这是一个刻意的版本边界控制:

  • 先验证 Skill Runtime 本身
  • 暂时不把“Skill 路由”这个变量引进来

关键代码:按 Skill 过滤当前 Tool 集

这段代码的作用是:让 Skill 真实改变当前 Runtime 的可用能力边界。

function getActiveToolDefinitions(skillDefinition?: SkillDefinition): ChatToolDefinition[] {
  const activeToolDefinitions = chatToolRegistry.listActive()

  if (!skillDefinition) {
    return activeToolDefinitions
  }

  const allowedToolNames = new Set(skillDefinition.allowedTools)

  return activeToolDefinitions.filter(toolDefinition => allowedToolNames.has(toolDefinition.name))
}

这一步非常关键,因为它说明:

  • Skill 不是文本层能力
  • Skill 是 Runtime 层能力

后面如果你要接 MCP、接更多 Skill,这种边界会非常有价值。

为什么还要新加 unit-convert

如果这一版只做 utility-skill,而不再新增任何 Tool,那它看起来会很像:

  • 把现有 Tool 打包进一个 Skill

这会让 Skill 的存在感偏弱。

所以我在 v0.0.7 里又补了一个很合适的新 Tool:

  • unit-convert

它的价值在于:

  • calculator / datetime / text-transform 一样,都是确定性任务
  • 非常贴近日常实用场景
  • 能让 utility-skill 更像一个完整的实用能力包

关键代码:unit-convert 的 schema

这段代码的作用是:让单位换算成为一个边界明确、可校验、可扩展的 Tool。

const unitConvertToolSchema = z.object({
  value: z.number().finite(),
  from: z.enum(supportedUnits),
  to: z.enum(supportedUnits),
})

这一版我刻意把范围控制得很小,只支持:

  • 长度
  • 重量
  • 温度

没有去碰:

  • 货币汇率
  • 存储单位
  • 实时换算

因为 v0.0.7 的重点不是“功能越多越好”,而是“Skill Runtime 是否站得住”。

关键代码:unit-convert 的 definition

这段代码的作用是:unit-convert 纳入统一 Tool Runtime,并声明它的结果是权威结果。

export const unitConvertToolDefinition: ChatToolDefinition<z.infer<typeof unitConvertToolSchema>> = {
  name: 'unit-convert',
  tool: unitConvertTool,
  schema: unitConvertToolSchema,
  normalizeArgs: normalizeUnitConvertToolArgs,
  formatInput: formatUnitConvertToolInput,
  getDisplayConfig: args => ({
    title: 'unit-convert',
    action: 'convert',
    inputPreview: formatUnitConvertToolInput(args),
  }),
  resultIsAuthoritative: true,
}

这里的 resultIsAuthoritative = true 很重要。

因为单位换算和计算题一样:

Tool 结果应该被视为权威事实,而不是让模型再自由改写。

这一版最真实的坑:Prompt 很重要,但版本主题也要克制

如果只看实现结构,v0.0.7 好像已经很完整了。

但真正做下来之后,我觉得最值得写的,反而是一个很现实的工程结论:

Prompt 很重要,但版本不能为了追求“更稳”就把一切都做成特判。

datetime 这种时间类问题上,我确实做过多轮 Prompt 收紧尝试,比如:

  • 明确要求时间、日期、星期问题优先使用 datetime
  • 禁止模型在 reasoning 里只说“应该调用工具”却不真正发起 tool_call

这些约束是有价值的,但我最后没有把这版写成“到处加特定问题兜底”的版本。

原因是 v0.0.7 的主题是:

  • 验证 Skill Runtime 是否成立

而不是:

  • 把所有边界问题都靠局部补丁兜住

这也是为什么当前版本保留的是:

  • Skill Registry
  • allowedTools
  • 统一 prompt 约束
  • Runtime 主链校验与错误透传

而不是把每一个相对日期表达都变成运行时特判。

前端这版最大的变化:现在渲染的是 Skill 下的 Tool 事件流

前端这版没有做新的 Skill UI,这是我故意的。

因为 v0.0.7 的重点不在“多一个标签”,而在于:

  • Skill 已经真实影响 Runtime
  • 前端仍然可以通过现有 reasoning / tool / text 协议感知这一变化

所以前端这一版主要延续的是:

  • useChatStream 消费结构化流
  • Tool card 显示工具调用过程
  • Skill 通过请求中的 options.skill 默认启用

关键代码:前端默认启用 utility-skill

这段代码的作用是:/instamind 默认工作在 utility-skill 模式下。

const DEFAULT_SKILL = 'utility-skill'

const requestBody: ChatRequest = {
  conversationId: conversationIdRef.current,
  messages: buildRequestMessages(nextMessages),
  options: {
    model: DEFAULT_MODEL,
    enableReasoning: true,
    skill: DEFAULT_SKILL,
  },
}

这一点让我能在不加新 UI 的情况下,先把 Skill Runtime 验证起来。

这版的真实回归,最说明问题的是什么

v0.0.7 我没有只看“代码能编译通过”,还单独做了一轮真实回归。

回归里比较关键的结论有这些:

稳定的部分

  • 普通开放式问答正常
  • calculator 正常
  • datetime(action=now) 正常
  • unit-convert 正常
  • text-transform 正常输入路径可用
  • 非法 JSON 会返回 tool-error

最说明版本边界的部分

  • utility-skill 已经能真实约束 Tool 使用范围
  • 多 Tool 仍然可以在 Skill 下保持统一输出风格
  • 但某些边界场景是否继续做更强兜底,已经不是这版的主问题,而是下一版是否继续打磨的问题

这个结论对我来说非常重要,因为它意味着:

v0.0.7 已经把 Skill Runtime 这条主链真正接通了,而版本主题也还保持清晰。

当前边界:这版已经做到了什么,还没做到什么

已经做到的

  • Skill Definition / Skill Registry 已落地
  • utility-skill 已能真实约束 Runtime
  • unit-convert 已接入
  • 版本材料和回归记录已同步

还没做的

  • 自动 Skill 路由
  • 多 Skill 串联
  • Skill 级记忆
  • Skill UI
  • MCP 接入
  • Agent 化执行

这些都不是遗漏,而是我在这版里刻意没做。

因为这篇文章真正想讲清楚的,不是“系统又多厉害了”,而是:

版本边界控制本身,就是 Runtime 架构能力的一部分。

这一版我最想留下的结论

如果要用一句话概括 v0.0.7,我会写:

这不是“多加一个 unit-convert”的版本,而是项目第一次正式把 Tool Runtime 往上抬了一层,长出了第一层 Skill Runtime。

再具体一点,这一版最值得记住的 4 个结论是:

  1. 多 Tool 之后,真正的难点不再是“能不能调 Tool”,而是“能不能稳定管理 Tool 边界”
  2. Skill 的价值不在于多一段 Prompt,而在于它是否真实约束了 Runtime
  3. 版本主题清晰,比把所有边界问题都塞进同一版更重要
  4. Tool、Skill、MCP、Agent 更像一条能力演进链,而不是并列功能清单

下一步会往哪走

如果继续往后推进,我觉得最自然的方向不会是立刻做 Agent,而是:

  • 继续收口 utility-skill
  • 评估下一版是继续做更多 Skill,还是进入 MCP 试点

如果说 Tool 是原子能力,Skill 是能力模式,那么下一步就会开始进入:

  • 外部能力接入标准
  • 更高层的任务模式
  • 更完整的 Agent Runtime

但那已经是后面的故事了。

最后

这个项目还会继续沿着 Skill Runtime、MCP、Agent Runtime 这些方向迭代下去。

如果这篇文章对你有帮助,欢迎到 GitHub 看看项目,也欢迎顺手点个 Star,这会是我继续更新下去的很大动力。

仓库地址: github.com/HWYD/ai-min…

不只是接个计算器:我是怎么把 Tool Calling 做成可扩展骨架的

作者 倾颜
2026年3月27日 00:02

本文对应项目版本:v0.0.5

在前一个版本里,我已经把项目的基础聊天链路搭起来了:

  • 服务端使用 LangChain.js + Ollama
  • 前端使用自定义 useChatStream
  • 内容渲染采用 Markdown + typed parts + Streamdown
  • 输入和协议校验使用 Zod

到这一步,项目虽然已经具备了“本地大模型对话 + 流式输出 + 多轮上下文”的基础能力,但它本质上还是一个纯文本问答系统。

v0.0.5 我想解决的问题,不是“再接一个功能”,而是让系统第一次具备调用外部能力的能力。

为了控制复杂度,这一版我只给自己定了一个目标:

先接一个最小 Tool,跑通完整 Tool Calling 链路,同时保留后续自然扩展的空间。

当前版本只接入了一个工具:

  • calculator

但这一版真正要验证的,不是“计算器能不能算题”,而是下面这些工程问题:

  • 模型怎么决定要不要调用工具?
  • 工具参数怎么校验?
  • 工具结果怎么回填给模型?
  • 前端怎么把推理、工具调用、最终答案区分开来展示?
  • 如果一次流式渲染异常,会不会影响下一次对话?

这些问题,才是 Tool Calling 从 Demo 走向工程实现时真正需要面对的部分。

项目效果

聊天主界面截图

chat1.png

推理过程 + tool part工具调用成功 + 最终答案完整展示图

toolsucess.png

这版到底想做什么

先说清楚 v0.0.5 的边界。

这版不是:

  • 多工具 Agent
  • 工具编排平台
  • LangGraph 工作流
  • 长期记忆系统

这版只做一件事:

用一个最小 Tool Calling 实践,验证当前聊天系统能不能从“只会回答”升级成“会调用能力、会返回结构化结果、还能保持工程可扩展”。

所以这一版的设计原则非常明确:

  • 最小可实现
  • 可扩展
  • 不推翻前一版架构
  • 不引入新的重型框架

总体架构

这一版的整体链路如下:

用户输入
  -> /api/chat
    -> LangChain ChatOllama
      -> 判断是否返回 tool_calls
        -> calculator
          -> ToolMessage 回填
            -> 最终答案流式返回前端

如果把这条链路拆开来看,前后端分别承担的职责其实很清楚。

服务端负责

  • 统一模型接入
  • 挂载当前可用工具
  • 解析模型返回的 tool_calls
  • Zod 校验工具参数
  • 执行工具
  • 把工具结果回填给模型
  • 输出结构化 NDJSON 流

前端负责

  • 读取 NDJSON 流
  • reasoning / tool / text 三类 part 消费事件
  • 渲染推理过程、工具调用状态和最终答案
  • 在多轮对话里只保留必要上下文

为什么我没有继续把返回内容做成“一段字符串”

如果系统只有普通对话,把响应内容当作一段 Markdown 字符串其实是够用的。

但到了 Tool Calling 场景,这种做法就不够了。

因为一次回答里其实会同时包含三类信息:

  1. 模型推理内容
  2. 工具调用过程与工具结果
  3. 最终回答正文

如果仍然全部塞进一段字符串里,前端就会遇到几个问题:

  • 无法单独折叠推理过程
  • 无法结构化展示 tool 调用状态
  • 无法清晰地区分“工具结果”和“模型最后组织出来的答案”
  • 后续增加更多工具时,协议会越来越乱

所以这一版我把 assistant 消息拆成了三类 part:

  • reasoning part
  • tool part
  • text part

这样前端拿到的就不再是“文本流”,而是一种结构化事件流

关键代码:消息 part 结构

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

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

export interface ToolPart extends BasePart {
    type: 'tool'
    toolName: string
    status: 'called' | 'completed' | 'failed'
    input: string
    output?: string
    error?: string
}

关键代码:流式 chunk 协议

export interface ToolStartChunk {
    type: 'tool-start'
    partId: string
    toolName: string
    input: string
}

export interface ToolEndChunk {
    type: 'tool-end'
    partId: string
    toolName: string
    input: string
    output: string
}

export type ChatStreamChunk =
    | StartChunk
    | ReasoningStartChunk
    | ReasoningDeltaChunk
    | ReasoningEndChunk
    | ToolStartChunk
    | ToolEndChunk
    | ToolErrorChunk
    | TextStartChunk
    | TextDeltaChunk
    | TextEndChunk
    | FinishChunk
    | ErrorChunk

我后来越来越确信一点:

对 Tool Calling 场景来说,最值得先做对的不是 UI,而是消息协议。

因为协议一旦分层清楚,前端展示、错误定位、后续扩展都会轻松很多。

服务端设计:统一模型接入层 + 最小 Tool Calling 闭环

这一版我有一个很明确的取舍:

不把服务端讲成“普通模型一套、工具模型一套”。

更准确的说法应该是:

服务端维护一个统一的大模型接入层,在运行时根据当前可用工具集合决定是否给模型挂载工具能力。

也就是说,本质上仍然是一个基础模型配置,只是运行时会根据 activeTools 决定是否调用 bindTools()

这样的好处是:

  • 架构更干净
  • 更符合主流 Tool Calling 的实现方式
  • 后续新增工具时,不需要推翻主链路

执行流分成三种情况

1. 当前有可用工具,但当前问题不需要工具

user message
  -> baseModel.bindTools(activeTools)
    -> model response
      -> no tool_calls
        -> final answer

特点:

  • 只调用一次模型
  • 不会多走一轮
  • 即使模型已经绑定工具,也可以直接正常回答

2. 当前有可用工具,且当前问题需要工具

user message
  -> baseModel.bindTools(activeTools)
    -> model response
      -> tool_calls
        -> Zod 校验
        -> execute tool
        -> append ToolMessage
        -> second model response
        -> final answer

这就是当前版本的两阶段最小闭环:

  • 第一阶段:模型决定是否调用工具
  • 第二阶段:工具执行完成后,再生成最终答案

3. 当前没有可用工具

user message
  -> baseModel
    -> final answer

这里要特别强调一下:

“当前没有可用工具”属于运行时能力状态,不是对用户问题做内容分类。

关键代码:统一模型接入与主执行链

function createBaseModel(request: ChatRequest, deps: ChatServiceDependencies) {
    return 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 baseModel = createBaseModel(request, deps)
const activeTools = getActiveTools()
const toolBoundModel =
    activeTools.length > 0 ? baseModel.bindTools(activeTools.map(toolDefinition => toolDefinition.tool)) : null

if (!toolBoundModel) {
    await streamDirectAnswer(baseModel, langChainMessages, context, writeChunk, () => closed)
    writeChunk({ type: 'finish' })
    return
}

这里的关键不是代码有多复杂,而是这个判断边界很清晰:

  • 有工具可用时,模型具备 tool calling 能力
  • 没有工具可用时,系统仍然可以正常工作

为什么 calculator 仍然值得单独做

很多人会觉得:

“先做一个计算器工具是不是太简单了?”

但从工程角度看,calculator 恰恰很适合做第一版 Tool Calling。

1. 它是确定性工具

输入同一个表达式,输出应该永远一致。

这意味着一旦结果错了,我们更容易判断问题出在:

  • 模型没有正确发起 tool call
  • tool 参数不合法
  • 工具执行失败
  • 工具结果回填后,模型又把答案组织错了

2. 它非常适合验证校验链路

tool_calls 不是模型说什么就执行什么。

在真正执行前,我仍然做了这几步:

  1. 工具存在性检查
  2. 参数归一化
  3. Zod.safeParse
  4. 执行工具

这套链路虽然不复杂,但正是 Tool Calling 从 Demo 走向工程实现时最关键的一步。

3. 它天然暴露“工具结果正确 ≠ 最终答案一定正确”这个问题

这一点是我这次实现里最值得记录的坑之一。

calculator 明明算出了正确结果,但第二阶段模型仍然可能:

  • 重新手算一遍
  • 把中间步骤写错
  • 输出和工具结果冲突的回答

tool3.png

这说明一个很现实的问题:

Tool Calling 的核心难点,不只是“会不会调工具”,还包括“调完工具以后,系统如何保证最终答案仍然可信”。

关键代码:calculator 工具定义

export const calculatorToolSchema = z.object({
    expression: z.string().min(1).max(200),
})

export function normalizeCalculatorExpression(expression: string): string {
    return expression
        .trim()
        .replaceAll('×', '*')
        .replaceAll('÷', '/')
        .replaceAll('(', '(')
        .replaceAll(')', ')')
        .replaceAll(/\s+/g, ' ')
}

export const calculatorTool = tool(
    async ({ expression }) => {
        const normalizedExpression = normalizeCalculatorExpression(expression)
        const result = evaluate(normalizedExpression)
        return String(result)
    },
    {
        name: 'calculator',
        description: '执行数学表达式计算',
        schema: calculatorToolSchema,
    }
)

关键代码:工具注册入口

const calculatorToolDefinition: ChatToolDefinition = {
    name: 'calculator',
    tool: calculatorTool,
    schema: calculatorToolSchema,
    normalizeArgs: normalizeCalculatorToolArgs,
    formatInput: formatCalculatorToolInput,
    resultIsAuthoritative: true,
}

const chatToolDefinitions = [calculatorToolDefinition]

export function getActiveChatToolDefinitions(): ChatToolDefinition[] {
    return chatToolDefinitions.filter(toolDefinition => toolDefinition.isAvailable?.() ?? true)
}

虽然当前只有一个工具,但这个注册入口已经把下一版的扩展位留出来了。

Zod 在这一版里不是配角

如果只看功能演示,很多人会把 Zod 当成附属工具。

但在实际实现里,Zod 是这一版稳定性的关键基础设施之一。

1. 请求输入校验

前端每次发送:

  • conversationId
  • messages[]
  • options

都要先经过请求 schema 校验。

2. Tool Call 参数校验

模型产出的 tool_calls 不是可信输入。

如果不做校验,模型只要给出一个奇怪参数,就可能直接把工具执行链路带崩。

这一版的处理方式是:

  • 先归一化参数
  • safeParse
  • 通过才执行
  • 失败就输出 tool-error

3. 前端流协议校验

前端消费的不是纯文本,而是结构化 NDJSON 事件流。

所以每个 chunk 进入渲染前,也要先经过 schema 校验。

这件事的价值在于:

  • 某一次流式异常不会直接污染整个状态树
  • 协议一旦有问题,更容易定位在“服务端输出错误”还是“前端消费错误”

关键代码:tool call 显式校验

const normalizedArgs = toolDefinition.normalizeArgs ? toolDefinition.normalizeArgs(toolCall.args) : toolCall.args
const parsedArgs = toolDefinition.schema.safeParse(normalizedArgs)

if (!parsedArgs.success) {
    toolErrors.push({
        id: toolCall.id,
        toolName: toolCall.name,
        input: formatToolInput({
            ...toolCall,
            args: normalizedArgs,
        }),
        message: createToolValidationErrorMessage(toolCall, parsedArgs.error),
    })
    continue
}

validatedToolCalls.push({
    ...toolCall,
    args: parsedArgs.data,
})

我后来越来越觉得:

在 Tool Calling 场景里,模型能力很重要,但真正决定系统稳定性的,往往是“模型之外”的校验与兜底。

前端设计:不是在拼字符串,而是在消费事件流

前端这一版我没有接 AI SDK,而是继续保留自定义 useChatStream

原因很简单:

这一版的目标是把 Tool Calling 跑通,而不是再引入一层新的聊天抽象。

useChatStream 真正做的事情

它处理的不是一段“完整文本”,而是一串结构化事件:

  • start
  • reasoning-start / reasoning-delta / reasoning-end
  • tool-start / tool-end / tool-error
  • text-start / text-delta / text-end
  • finish
  • error

这意味着前端不再是“把文本不断 append 到一个字符串里”,而是在做更细粒度的状态消费。

这带来的直接收益

  1. 推理过程可以单独折叠
  2. 工具调用可以单独展示
  3. 工具结果和最终正文可以自然区分
  4. 以后接更多工具时,前端协议层不需要推翻重来

关键代码:消费 NDJSON 流

async function consumeNdjsonStream(stream: ReadableStream<Uint8Array>, onChunk: (chunk: ChatStreamChunk) => void) {
    const reader = stream.getReader()
    const decoder = new TextDecoder()
    let buffer = ''

    while (true) {
        const { done, value } = await reader.read()

        if (done) {
            break
        }

        buffer += decoder.decode(value, { stream: true })
        const lines = buffer.split('\n')
        buffer = lines.pop() ?? ''

        for (const line of lines) {
            const parsedChunk = chatStreamChunkSchema.safeParse(JSON.parse(line))

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

            onChunk(parsedChunk.data)
        }
    }
}

关键代码:按 chunk 更新消息

case 'tool-start': {
    const messageId = activeStreamRef.current.messageId
    if (!messageId) return

    updateMessages(current => appendPart(current, messageId, createToolPart(chunk.partId, chunk.toolName, chunk.input)))
    return
}

case 'text-delta': {
    const messageId = activeStreamRef.current.messageId
    const textPartId = activeStreamRef.current.textPartId

    if (!messageId || textPartId !== chunk.partId) {
        return
    }

    updateMessages(current => appendTextualPartDelta(current, messageId, chunk.partId, 'text', chunk.delta))
    return
}

这类实现让我很明确地感受到一件事:

一旦前端开始消费结构化事件,聊天 UI 就从“渲染字符串”升级成了“渲染系统状态”。

这版最值得记录的几个坑

如果这篇文章只写最终方案,会显得过于平滑。

但真实开发里,v0.0.5 其实踩了不少坑,我觉得这些坑反而很值得记录。

坑 1:工具结果是对的,模型最终回答却可能是错的

例如 calculator 已经算出了正确结果,但模型在第二阶段生成最终答案时,仍然可能:

  • 重复手算
  • 口算出错
  • 输出与工具结果不一致的内容

tool3.png

后来我在运行时加了一个更明确的策略:

  • calculator 这类确定性工具,工具结果具有权威性
  • 在必要时优先直出工具结果对应的最终答案

也就是说:

模型可以组织语言,但不能推翻确定性工具的结果。

坑 2:普通问答的流式体验“不像真流”

早期我把第一阶段写成了非流式调用,结果带来的体验是:

  • 页面先等很久
  • 然后一次性吐出一大块内容

这让我意识到:

真正的流式体验,不能靠“先等模型完整返回,再拼装输出”来模拟。

后来第一阶段也切回了真正的流式消费,普通问题的体验才重新正常。

坑 3:前端一次渲染异常,会污染下一轮请求

某次流式异常后,前端残留了一条空的 assistant placeholder。下一次请求时,它又被带回服务端,最终触发了:

  • messages[].parts 为空
  • 后端 Zod 校验失败
  • 直接返回 400

后来我补了两层兜底:

  • 发送前清理瞬态脏消息
  • 只把有效 text part 组装进请求

这才保证“一次异常不会影响下一次对话”。

坑 4:模型绑定了工具,不代表它一定会乖乖用工具

理论上模型已经绑定了 calculator,但实际运行里,它仍然可能:

  • 先自己推理
  • 输出伪调用文本
  • 在 reasoning 里长篇展开错误步骤

tool2.png

这说明:

Tool Calling 的稳定性,不能只依赖模型自觉。

这也是为什么提示词设计、参数校验、结果策略这些工程细节都很重要。

当前版本已经做到什么程度

v0.0.5 为止,项目已经具备了这些能力:

  • 支持普通问答
  • 支持单工具 calculator
  • 支持 Tool Calling 可视化
  • 支持本地多轮上下文
  • 支持 reasoning / tool / text 三类结构化展示
  • 支持流式错误兜底

但我也明确保留了这些边界:

  • 不做多工具调度
  • 不做完整 Agent loop
  • 不做工具权限系统
  • 不做工具管理后台
  • 不做长期记忆

这不是因为它们不重要,而是因为这一版的主题非常明确:

先把单工具 Tool Calling 的最小工程闭环做对。

我对 v0.0.5 的总结

如果要用一句话概括这一版,我会这么说:

v0.0.5 的重点不是做了一个计算器,而是把当前聊天系统从“只有模型回答”升级成了“模型可以发起 Tool Calls、服务端可以校验并执行工具、前端可以结构化展示结果”的最小闭环。

这一版做完以后,项目其实第一次真正具备了“能力扩展”的基础。

从这个版本往后,再增加新的 Tool,就不再是“重新造一套聊天系统”,而是在现有骨架上自然长出新的能力。

下一步可以怎么做

如果继续往后迭代,我比较关心的是三个方向:

  1. 增加更多 Tool,验证单 Tool 骨架能否自然扩展
  2. 引入上下文窗口或摘要策略,避免多轮上下文过长
  3. 继续完善 Tool Result 策略,让确定性工具和非确定性工具的回答方式更清晰

项目地址

GitHub:

[github.com/HWYD/ai-min…]

如果这篇文章或这个项目对你有帮助,欢迎点个 Star 支持一下。

后续我也会继续按版本节奏,把这个项目一步步迭代下去。

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

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

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

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

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

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

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

项目前端截图.png


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

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

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

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

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

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

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

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

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

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

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


二、这次方案怎么定?

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

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

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

前端渲染
  └─ Streamdown 渲染 Markdown

这套方案有几个关键词:

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

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

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

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

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

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

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

这一层只负责一件事:

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

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

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

2. 消息模型层:typed parts

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

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

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

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

export type MindMessagePart = TextPart | ReasoningPart

这样做的意义很大:

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

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

3. 传输协议层:NDJSON 流

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

原因很简单:

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

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

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

你可以把它理解成:

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

    if (!content) continue

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

  return result
}

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

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

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

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

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

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

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

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

第三,后续扩展空间更好

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

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

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


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

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

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

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

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

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

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

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

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

也就是说:

  • 消息是容器
  • 内容是 parts

当前只落了两种 part:

  • text
  • reasoning

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

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


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

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

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

核心思路是:

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

关键代码如下:

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

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

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

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

这里有几个实现要点。

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

这是协议设计的核心。

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

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

为什么还要多一个 partId

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

所以:

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

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

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

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

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

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

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

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

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

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

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

原因是:

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

1. 多轮上下文怎么做?

方案非常简单:

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

这就是最小多轮上下文。

代码也很直接:

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

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

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

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

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

这么做的原因是:

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

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

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

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

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

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

这里的设计好处是:

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

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

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

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

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

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

1. 请求入口校验

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

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

对应 schema:

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

这一步的价值在于:

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

2. 流式 chunk 校验

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

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

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

这一步非常关键。

因为它能防止:

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

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

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


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

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

但 AI 聊天有个典型特征:

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

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

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

这也是为什么我选了 Streamdown

基础用法其实很简单

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

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

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

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

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

那么很容易出现:

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

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

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

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

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

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


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

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

原因主要是:

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

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

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

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

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

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


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

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

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

那这套方案其实很适合。

它的特点是:

优点

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

目前刻意保留的简化点

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

换句话说,这是一套:

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


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

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

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

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

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

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

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

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

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

前后端都知道:

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

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

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

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

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

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


结语

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

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

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

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

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

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

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

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

📦 完整代码

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

👉 GitHub Release - v0.0.4

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

React.cache:让你的服务器组件告别“重复劳动”

作者 helloweilei
2026年3月23日 16:17

一句话总结:它是专为 React 服务器组件(RSC)设计的“记忆化”利器,能让你在同一个请求中,多次调用同一个函数,却只执行一次真实逻辑。

想象一下这个场景:你点了三份同样的外卖(同一篇博客文章的数据),商家会让三个厨师各做一遍,还是让一个厨师做一份然后分装三份?当然是后者!React.cache 就是那个帮你“只做一份”的调度员。


一、🤔 为什么需要 React.cache?从“外卖比喻”说起

在 Next.js 的 App Router 中,一个页面通常由多个组件组成:布局、页面本身、generateMetadata……每个组件都可能需要同样的数据。

没有缓存时

// 这三个地方都要用到文章数据,每次都重新查数据库!😱
// app/article/layout.tsx
const { title } = await getArticle()  // 查第一次

// app/article/page.tsx
const { title } = await getArticle()  // 查第二次

// app/article/layout.tsx 的 generateMetadata
const { title } = await getArticle()  // 查第三次

结果是:三次数据库查询,三次网络请求,三次等待时间。 这在复杂页面里就是性能灾难。

有了 React.cache 之后

// utils.ts
import { cache } from 'react'

export const getArticle = cache(async (id) => {
  return await db.article.findUnique({ where: { id } })
})

无论你在多少个组件里调用 getArticle(id),只要 id 相同,真正的数据库查询只执行一次,后续调用直接返回缓存结果。

二、📦 核心 API:超级简单

React.cache 的 API 极其简洁,长这样:

import { cache } from 'react'

const cachedFn = cache(originalFn)
  • 入参:一个函数(可以是同步或异步的)
  • 返回值:一个具有相同签名的“记忆化”版本
  • 缓存依据:函数调用的所有参数(严格相等比较 Object.is

看个实际例子:

import { cache } from 'react'

const fetchWeather = async (city) => {
  console.log(`🌤️ 真实请求: ${city}`)
  const res = await fetch(`https://api.weather.com/${city}`)
  return res.json()
}

const getCachedWeather = cache(fetchWeather)

// 第一次调用:执行真实请求
const nyc1 = await getCachedWeather('New York')  // 日志输出

// 第二次调用(相同参数):直接返回缓存,不执行函数
const nyc2 = await getCachedWeather('New York')  // 无日志

// 不同参数:重新执行
const london = await getCachedWeather('London')   // 日志输出

注意:缓存不仅存成功结果,也缓存错误。如果第一次调用抛异常,后续相同参数的调用也会抛出同样的异常。

三、🎯 实战场景一:共享数据快照

这是 RSC 中最经典的使用场景:多个组件需要同一份数据

在掘金的一篇实战文章中,作者展示了这样一个结构:

// app/article/utils.ts
import { cache } from 'react'
import { db } from '@/lib/db'

export const getArticle = cache(async (id: string) => {
  // 模拟耗时的数据库查询
  await new Promise(r => setTimeout(r, 2000))
  return await db.article.findUnique({ where: { id } })
})

然后在布局和页面中同时使用:

// app/article/[id]/layout.tsx
import { getArticle } from './utils'

export default async function Layout({ params: { id } }) {
  const { title } = await getArticle(id)  // ← 调用1
  return (
    <div className="banner">
      您正在阅读:{title}
      {children}
    </div>
  )
}

// app/article/[id]/page.tsx
import { getArticle } from './utils'

export default async function Page({ params: { id } }) {
  const { title } = await getArticle(id)  // ← 调用2,走缓存
  return <h1>{title}</h1>
}

效果:两个组件虽然都调用了 getArticle,但数据库只查询了一次。而且因为用的是同一个记忆化函数,它们拿到的数据快照完全一致——不会出现布局显示“标题A”,页面显示“标题B”的乌龙。

四、⚡ 实战场景二:预加载数据(Preload Pattern)

这是官方文档特别推荐的一个高阶技巧:在组件真正需要数据之前,提前发起请求,利用缓存把数据“预热”

import { cache } from 'react'
import { db } from '@/lib/db'

const getUser = cache(async (id: string) => {
  return await db.user.findUnique({ where: { id } })
})

// 预加载函数:只调用,不 await
function preloadUser(id: string) {
  void getUser(id)  // 启动数据获取,但不等待
}

// 实际使用的组件
async function UserProfile({ id }: { id: string }) {
  const user = await getUser(id)  // 如果预加载已完成,这里几乎瞬间返回
  return <div>{user.name}</div>
}

// 页面组件
export default async function Page({ params }: { params: { id: string } }) {
  preloadUser(params.id)  // ← 立即开始获取用户数据
  // ... 其他计算工作 ...
  return <UserProfile id={params.id} />  // ← 用到时可能已经在缓存里了
}

这个模式的好处是并行化:在等待预加载数据的同时,React 可以继续执行其他计算或渲染其他组件,而不是串行等待。

五、⚠️ 三个你必须知道的陷阱

陷阱1:React.cache 只适用于服务器组件

这是官方文档明确强调的:cache 仅供与 React 服务器组件一起使用。在客户端组件('use client')里用,不会报错,但缓存不生效

为什么?因为缓存的访问是通过 React 内部的**请求上下文(request storage)**实现的,而这个上下文只在服务器端渲染时才存在。

社区里已经有人在 Next.js Discord 里问过这个问题:想把 React.cache 用在客户端组件里做请求去重,结果是——不管用。建议用 React Query、SWR 等专门的客户端缓存方案。

陷阱2:不同记忆化函数,不共享缓存

这是最容易踩的坑!看这段错误代码:

// ❌ 错误示例
function ComponentA() {
  const getData = cache(fetchData)  // 每次渲染创建新的缓存函数
  const data = getData(id)
}

function ComponentB() {
  const getData = cache(fetchData)  // 又一个独立缓存函数
  const data = getData(id)
}

两个组件各创自己的记忆化函数,缓存互相隔离,重复劳动依然发生。

正确做法:把 cache 调用放在模块顶层,导出一个共享的缓存函数:

// ✅ 正确:导出同一个缓存函数
// utils/data.ts
import { cache } from 'react'

export const getData = cache(fetchData)

// 组件A和B都 import 同一个 getData

陷阱3:在组件外部调用,不触发缓存

import { cache } from 'react'

const getUser = cache(async (id) => {
  return await db.user.findUnique({ where: { id } })
})

// ❌ 在组件外部调用,不会写入缓存
await getUser('123')

export default async function Page() {
  // ✅ 在组件内部调用,会使用缓存
  const user = await getUser('123')
}

React 只在组件渲染期间提供缓存上下文,外部调用虽然能执行函数,但缓存不会被读取或写入。

六、📊 cache vs useMemo vs memo:一张图看懂

很多初学者会混淆这几种“记忆化”机制,官方文档给了很清晰的区分:

API 适用场景 缓存范围 缓存依据 生命周期
React.cache 服务器组件 跨组件共享 函数参数 单次请求
useMemo 客户端组件 单个组件实例 依赖数组 组件生命周期
React.memo 客户端组件 组件渲染结果 Props 浅比较 组件生命周期

简单理解:

  • React.cache“一个请求内,所有人共用一份”(餐厅版:一个厨师做一份菜,分给三桌客人)
  • useMemo“一个组件内,重复渲染不重算”(餐厅版:同一桌客人反复点同一道菜,后厨不重做)
  • React.memo“Props 没变就不重渲染”(餐厅版:客人没换菜,服务员就不重新下单)

七、🔗 与 Next.js 的深度集成

在 Next.js 中,React.cache官方推荐的数据库查询缓存方案。而且 Next.js 还在持续优化它们的配合:

  • 自动去重:最新版本的 Next.js 修复了在 "use cache" 函数中使用 React.cache 时的去重问题
  • 静态渲染 + 缓存:当页面使用 export const revalidate = 3600 时,React.cache 会在每次重新渲染时清空缓存,配合 revalidate 实现定时更新

GitHub Trending 上的热门 UI 项目也大量使用 React.cache 来缓存组件文档和代码高亮的结果,提升首屏加载速度达 75%

八、🎯 什么时候用 React.cache?

✅ 推荐场景 ❌ 不推荐场景
在 RSC 中多次调用同一个数据获取函数 在客户端组件中
布局 + 页面 + 元数据都需要同一份数据 数据只在单个组件中使用一次
预加载数据,提升性能 需要跨请求持久化缓存(用 Redis/CDN)
数据库查询、API 调用、复杂计算 数据在客户端频繁变化

九、💡 总结:一张“外卖调度单”

回到开头的比喻。React.cache 就像餐厅里的智能调度系统

  • 它记住了“谁点了什么菜”
  • 发现重复订单时,直接复用已有的备菜
  • 所有菜品在同一批次送达,保证口味一致

核心记忆点

  1. 适用场景:仅限 React 服务器组件(RSC)
  2. 缓存依据:函数的所有参数(严格相等)
  3. 共享规则:必须使用同一个记忆化函数实例
  4. 生命周期:每次请求独立,请求结束缓存清空
❌
❌