阅读视图

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

Next.js第六课 - 数据获取

上节我们学习了服务端组件和客户端组件的区别,本节来深入了解 Next.js 中的数据获取。Next.js 提供了灵活且强大的数据获取方式,掌握好这些知识能让你构建出性能优异的应用。

数据获取概述

在 Next.js 中,有多种数据获取方式:

  1. 静态生成(SSG)- 构建时生成 HTML
  2. 服务器端渲染(SSR)- 每次请求时生成 HTML
  3. 增量静态再生成(ISR)- 定期重新生成静态页面
  4. 客户端数据获取 - 在浏览器中获取数据

选择哪种方式取决于你的数据特性:是否经常变化、是否需要 SEO、用户是否需要看到最新数据等。

服务端组件数据获取

在服务端组件中,你可以直接使用 fetch 或任何数据获取库,这相比传统的 React 方式要简单很多。

基本数据获取

最简单的方式就是直接在组件中使用 async/await:

// app/posts/page.tsx
async function getPosts() {
  const res = await fetch('https://api.example.com/posts')

  if (!res.ok) {
    throw new Error('获取文章失败')
  }

  return res.json()
}

export default async function PostsPage() {
  const posts = await getPosts()

  return (
    <div>
      <h1>博客文章</h1>
      <ul>
        {posts.map((post: any) => (
          <li key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.excerpt}</p>
          </li>
        ))}
      </ul>
    </div>
  )
}

直接访问数据库

服务端组件可以直接访问数据库,不需要创建 API 层:

// app/users/page.tsx
import { db } from '@/lib/db'

export default async function UsersPage() {
  const users = await db.user.findMany({
    orderBy: { createdAt: 'desc' },
  })

  return (
    <div>
      <h1>用户列表</h1>
      {users.map((user) => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  )
}

错误处理

处理数据获取中的错误是很重要的:

// app/products/[id]/page.tsx
export default async function ProductPage({
  params,
}: {
  params: { id: string }
}) {
  let product

  try {
    const res = await fetch(`https://api.example.com/products/${params.id}`)

    if (!res.ok) {
      if (res.status === 404) {
        notFound()
      }
      throw new Error('获取产品失败')
    }

    product = await res.json()
  } catch (error) {
    return <div>加载产品时出错</div>
  }

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
    </div>
  )
}

缓存和重新验证

Next.js 的缓存系统非常强大,理解它能让你的应用性能提升很多。

默认缓存行为

Next.js 默认会缓存 fetch 请求,这意味着相同的数据请求会被缓存起来,避免重复获取:

// 默认:自动缓存
export default async function Page() {
  const res = await fetch('https://api.example.com/data')
  const data = await res.json()

  return <div>{/* ... */}</div>
}

禁用缓存

如果数据需要实时更新,可以禁用缓存:

// 不缓存:每次都获取新数据
export default async function Page() {
  const res = await fetch('https://api.example.com/data', {
    cache: 'no-store',
  })
  const data = await res.json()

  return <div>{/* ... */}</div>
}

设置重新验证时间

最常用的方式是设置缓存时间:

// 缓存 10 秒后重新验证
export default async function Page() {
  const res = await fetch('https://api.example.com/data', {
    next: { revalidate: 10 },
  })
  const data = await res.json()

  return <div>{/* ... */}</div>
}

按标签重新验证

给数据加上标签,可以在数据更新时手动刷新缓存:

// 获取时添加标签
export default async function Page() {
  const res = await fetch('https://api.example.com/posts', {
    next: { tags: ['posts'] },
  })
  const posts = await res.json()

  return <div>{/* ... */}</div>
}

// 在 API 路由中手动重新验证
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'

export async function POST(request: Request) {
  const tag = await request.json()
  revalidateTag(tag)
  return Response.json({ revalidated: true })
}

渲染策略

静态渲染(默认)

静态渲染意味着页面在构建时就生成好了 HTML:

// app/blog/page.tsx
// 构建时生成静态 HTML
export default async function BlogPage() {
  const posts = await getPosts()

  return (
    <div>
      {posts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  )
}

动态渲染

每次请求都重新渲染:

// app/dashboard/page.tsx
// 每次请求都渲染
export const dynamic = 'force-dynamic'

export default async function DashboardPage() {
  const user = await getCurrentUser()

  return <div>欢迎,{user.name}</div>
}

增量静态再生成(ISR)

结合静态和动态的优点:

// app/products/page.tsx
// 每 60 秒重新生成页面
export const revalidate = 60

export default async function ProductsPage() {
  const products = await getProducts()

  return (
    <div>
      {products.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  )
}

并行数据获取

当需要获取多个独立的数据源时,应该并行获取以提高性能:

// app/dashboard/page.tsx
export default async function DashboardPage() {
  // 并行获取数据
  const [user, posts, analytics] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchAnalytics(),
  ])

  return (
    <div>
      <UserProfile user={user} />
      <PostsList posts={posts} />
      <AnalyticsChart data={analytics} />
    </div>
  )
}

客户端数据获取

虽然服务端数据获取更推荐,但有时候也需要在客户端获取数据:

使用 useEffect

传统的方式:

'use client'

import { useState, useEffect } from 'react'

export default function ClientDataFetching() {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    async function fetchData() {
      try {
        const res = await fetch('https://api.example.com/data')
        const json = await res.json()
        setData(json)
      } catch (err) {
        setError(err.message)
      } finally {
        setLoading(false)
      }
    }

    fetchData()
  }, [])

  if (loading) return <div>加载中...</div>
  if (error) return <div>错误: {error}</div>

  return <div>{/* 渲染数据 */}</div>
}

使用 SWR

SWR 是一个很流行的数据获取库:

'use client'

import useSWR from 'swr'

const fetcher = (url: string) => fetch(url).then((res) => res.json())

export default function Profile() {
  const { data, error, isLoading } = useSWR(
    'https://api.example.com/user',
    fetcher
  )

  if (error) return <div>加载失败</div>
  if (isLoading) return <div>加载中...</div>

  return <div>你好,{data.name}</div>
}

加载状态

Next.js 提供了优雅的加载状态处理方式:

loading.tsx 文件

创建 loading.tsx 文件会自动显示加载状态:

// app/posts/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-4 bg-gray-200 w-3/4 mb-4"></div>
      <div className="h-4 bg-gray-200 w-1/2 mb-4"></div>
      <div className="h-4 bg-gray-200 w-5/6"></div>
    </div>
  )
}

实用建议

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

优先使用服务端组件

实际开发中,我发现服务端数据获取不仅性能更好,代码也更简洁:

// 推荐这样做 - 服务端组件数据获取
export default async function Page() {
  const data = await fetchData()
  return <div>{data.title}</div>
}

// 除非有特殊需求,否则避免客户端数据获取
'use client'
export default function Page() {
  const [data, setData] = useState(null)
  useEffect(() => {
    fetchData().then(setData)
  }, [])
  return <div>{data?.title}</div>
}

并行获取数据

这个小技巧特别有用——如果多个数据源是独立的,应该并行获取来提升性能:

// 推荐这样做 - 并行获取,速度更快
const [user, posts] = await Promise.all([
  fetchUser(),
  fetchPosts(),
])

// 避免这种情况 - 串行获取会拖慢速度
const user = await fetchUser()
const posts = await fetchPosts() // 要等 user 完成才开始

使用适当的缓存策略

根据数据特性选择缓存策略,这个在实际项目中特别重要:

// 静态内容可以长时间缓存
const posts = await fetch('https://api.com/posts', {
  next: { revalidate: 3600 },
})

// 实时数据建议不缓存
const stockPrices = await fetch('https://api.com/stocks', {
  cache: 'no-store',
})

总结

本节我们详细学习了 Next.js 的数据获取机制,包括服务端和客户端获取、缓存策略、渲染策略等。掌握好这些知识,你就能根据不同的场景选择最合适的数据获取方式,构建出高性能的应用。

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

原文地址: blog.uuhb.cn/archives/ne…

Next.js 14 + wagmi v2 构建 NFT 市场:从列表渲染到实时更新的完整链路

背景

上个月,我接手了一个新的 Web3 项目:为一个基于 Base 链的 NFT 系列开发一个轻量级的交易市场前端。核心需求很简单:展示该系列的所有 NFT,显示每个 NFT 的当前挂单价格,并且当用户购买或取消挂单时,页面上的信息要能实时更新,无需手动刷新。

技术栈选型很明确:Next.js 14(App Router)、TypeScript、Tailwind CSS,以及 Web3 交互的核心——wagmi v2 和 viem。我心想,这不过是把链上数据读出来,监听几个事件,再渲染成列表,用 useAccountuseReadContract 不就搞定了吗?结果,从第一行代码开始,坑就一个接一个地来了。

问题分析

我的初始思路非常直接:在页面组件里,用 wagmi 的 useReadContract 读取 NFT 合约的 totalSupply,然后循环获取每个 Token 的元数据和挂单信息,最后用 useEffect 监听 ListingUpdatedTransfer 事件来触发数据重拉。

但一上手就发现了几个致命问题:

  1. 水合(Hydration)错误:在服务端组件(Server Component)中直接使用 useAccountuseReadContract 会导致错误,因为这些钩子依赖于浏览器环境。
  2. 性能灾难:如果我有 1000 个 NFT,难道要发起 1000+ 次 RPC 调用吗?页面加载会慢到无法接受。
  3. 实时更新失效:简单地用 useEffect 监听事件,在用户切换钱包或断开连接时,监听器会混乱,导致更新不及时或重复更新。
  4. 状态同步难题:交易(购买、挂单)提交后,如何优雅地等待链上确认,并立即更新 UI,而不是等用户手动刷新?

最初的方案完全走不通。我意识到,必须把服务端初始渲染、客户端状态管理、批量数据获取和事件驱动更新这几个环节拆解开,设计一个更清晰的架构。

核心实现

1. 架构分层:服务端获取初始数据

首先,我放弃了在页面组件里直接调用 Web3 钩子获取所有数据的想法。对于 NFT 列表这种相对静态的初始数据,应该在服务端获取。我创建了一个服务端函数,使用 Viem 的公共客户端(Public Client)来读取链上数据。

关键点:在 App Router 中,我们可以在 Server Component 或 Server Action 里直接与区块链交互,无需钱包连接。这完美解决了初始渲染的问题。

// app/api/nfts/route.ts
import { createPublicClient, http } from 'viem';
import { base } from 'viem/chains';

// 初始化一个不需要钱包的公共客户端
const publicClient = createPublicClient({
  chain: base,
  transport: http(process.env.NEXT_PUBLIC_RPC_URL),
});

// NFT 合约 ABI 片段
const NFT_ABI = [
  {
    name: 'totalSupply',
    type: 'function',
    stateMutability: 'view',
    inputs: [],
    outputs: [{ type: 'uint256' }],
  },
  {
    name: 'tokenURI',
    type: 'function',
    stateMutability: 'view',
    inputs: [{ name: 'tokenId', type: 'uint256' }],
    outputs: [{ type: 'string' }],
  },
] as const;

// 市场合约 ABI 片段
const MARKET_ABI = [
  {
    name: 'listings',
    type: 'function',
    stateMutability: 'view',
    inputs: [{ name: 'tokenId', type: 'uint256' }],
    outputs: [
      { name: 'seller', type: 'address' },
      { name: 'price', type: 'uint256' },
      { name: 'isActive', type: 'bool' },
    ],
  },
] as const;

export async function GET() {
  try {
    const totalSupply = await publicClient.readContract({
      address: process.env.NEXT_PUBLIC_NFT_CONTRACT as `0x${string}`,
      abi: NFT_ABI,
      functionName: 'totalSupply',
    });

    const nftDataPromises = [];
    // 注意:这里用 Number 转换只适用于总量不大的情况,真实项目需考虑 BigInt
    for (let i = 0; i < Number(totalSupply); i++) {
      const promise = Promise.all([
        // 获取元数据 URI
        publicClient.readContract({
          address: process.env.NEXT_PUBLIC_NFT_CONTRACT as `0x${string}`,
          abi: NFT_ABI,
          functionName: 'tokenURI',
          args: [BigInt(i)],
        }),
        // 获取挂单信息
        publicClient.readContract({
          address: process.env.NEXT_PUBLIC_MARKET_CONTRACT as `0x${string}`,
          abi: MARKET_ABI,
          functionName: 'listings',
          args: [BigInt(i)],
        }),
      ]).then(([tokenURI, listing]) => ({
        tokenId: i,
        tokenURI,
        listing,
      }));
      nftDataPromises.push(promise);
    }

    const nfts = await Promise.all(nftDataPromises);
    return Response.json({ nfts });
  } catch (error) {
    console.error('Failed to fetch NFTs:', error);
    return Response.json({ error: 'Fetch failed' }, { status: 500 });
  }
}

这里有个坑:直接循环调用 RPC 在 NFT 数量多时确实慢。在生产环境中,你应该考虑让合约本身返回批量数据,或者使用 The Graph 这类索引服务。我这里为了演示核心流程,先采用简单循环。

2. 客户端状态与实时更新

服务端提供了初始数据,但购买、挂单等交互后的实时更新必须在客户端处理。我创建了一个客户端组件 NftList,它接收服务端的初始数据,并负责管理动态状态。

实时更新的核心是 监听链上事件。wagmi v2 提供了 useWatchContractEvent 钩子,但直接用在列表组件里会导致每个 NFT 卡片都创建一个监听器,性能极差。我的方案是:在父级组件只监听市场合约的全局事件。

// components/nft-list.tsx
'use client';

import { useEffect, useState } from 'react';
import { useWatchContractEvent } from 'wagmi';
import { NftCard } from './nft-card';

// 市场合约 ABI 事件片段
const MARKET_EVENT_ABI = [
  {
    type: 'event',
    name: 'ListingUpdated',
    inputs: [
      { indexed: true, name: 'tokenId', type: 'uint256' },
      { indexed: false, name: 'seller', type: 'address' },
      { indexed: false, name: 'price', type: 'uint256' },
      { indexed: false, name: 'isActive', type: 'bool' },
    ],
  },
] as const;

interface NftListProps {
  initialNfts: Array<{
    tokenId: number;
    tokenURI: string;
    listing: [string, bigint, boolean];
  }>;
}

export function NftList({ initialNfts }: NftListProps) {
  // 使用服务端数据初始化状态
  const [nfts, setNfts] = useState(initialNfts);

  // 关键:监听全局的 ListingUpdated 事件
  useWatchContractEvent({
    address: process.env.NEXT_PUBLIC_MARKET_CONTRACT as `0x${string}`,
    abi: MARKET_EVENT_ABI,
    eventName: 'ListingUpdated',
    onLogs(logs) {
      console.log('ListingUpdated logs:', logs);
      // 当事件触发时,更新对应 NFT 的挂单信息
      logs.forEach((log) => {
        const { tokenId, price, isActive } = log.args;
        if (tokenId !== undefined) {
          setNfts((prev) =>
            prev.map((nft) =>
              nft.tokenId === Number(tokenId)
                ? {
                    ...nft,
                    listing: [log.args.seller || '0x', price || 0n, isActive || false],
                  }
                : nft
            )
          );
        }
      });
    },
  });

  // 一个手动刷新函数,用于在交易确认后主动触发(作为兜底)
  const refreshData = async () => {
    const res = await fetch('/api/nfts');
    const data = await res.json();
    if (data.nfts) setNfts(data.nfts);
  };

  return (
    <div>
      <button onClick={refreshData} className="mb-4 p-2 bg-gray-200 rounded">
        手动刷新数据
      </button>
      <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
        {nfts.map((nft) => (
          <NftCard key={nft.tokenId} nft={nft} onActionSuccess={refreshData} />
        ))}
      </div>
    </div>
  );
}

注意这个细节useWatchContractEvent 的回调函数中,log.args 的类型可能是 undefined,必须做防御性判断,否则 TypeScript 会报错,运行时也可能崩溃。

3. 交易交互与乐观更新

用户点击“购买”时,如果等到交易上链确认(可能十几秒)才更新 UI,体验会很差。我采用了 乐观更新(Optimistic Update) 的策略:先立即更新本地状态,假设交易会成功;如果交易失败,再回滚状态。

// components/nft-card.tsx
'use client';

import { useState } from 'react';
import { useAccount, useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { parseEther } from 'viem';

interface NftCardProps {
  nft: {
    tokenId: number;
    tokenURI: string;
    listing: [string, bigint, boolean];
  };
  onActionSuccess?: () => void;
}

export function NftCard({ nft, onActionSuccess }: NftCardProps) {
  const { address } = useAccount();
  const [isUpdating, setIsUpdating] = useState(false);
  const { data: hash, writeContract, error } = useWriteContract();
  const { isLoading: isConfirming } = useWaitForTransactionReceipt({ hash });

  const [seller, price, isActive] = nft.listing;

  const handleBuy = async () => {
    if (!address || !isActive) return;

    setIsUpdating(true); // 开始乐观更新
    // 这里可以立即调用父组件传递的回调,或者用状态管理更新本地列表
    // 为了简化,我们假设父组件会通过事件监听更新,这里只处理自身加载状态

    try {
      writeContract({
        address: process.env.NEXT_PUBLIC_MARKET_CONTRACT as `0x${string}`,
        abi: [
          {
            name: 'buyToken',
            type: 'function',
            stateMutability: 'payable',
            inputs: [{ name: 'tokenId', type: 'uint256' }],
            outputs: [],
          },
        ] as const,
        functionName: 'buyToken',
        args: [BigInt(nft.tokenId)],
        value: price,
      });
    } catch (err) {
      console.error('Buy failed:', err);
      setIsUpdating(false); // 回滚乐观更新
    }
  };

  // 交易确认后的处理
  useEffect(() => {
    if (hash && !isConfirming) {
      console.log('Transaction confirmed!');
      setIsUpdating(false);
      onActionSuccess?.(); // 通知父组件刷新数据
    }
  }, [hash, isConfirming, onActionSuccess]);

  return (
    <div className="border p-4 rounded-lg shadow">
      <img src={`https://ipfs.io/ipfs/${nft.tokenURI.split('://')[1]}`} alt={`NFT ${nft.tokenId}`} className="w-full h-48 object-cover rounded" />
      <div className="mt-2">
        <p className="font-bold">Token ID: {nft.tokenId}</p>
        <p>Price: {price ? parseFloat(parseEther(price.toString()).toString()).toFixed(4)} ETH</p>
        <p>Status: {isActive ? 'For Sale' : 'Not Listed'}</p>
      </div>
      {isActive && address !== seller && (
        <button
          onClick={handleBuy}
          disabled={isUpdating || isConfirming}
          className={`mt-2 w-full py-2 rounded ${isUpdating || isConfirming ? 'bg-gray-400' : 'bg-blue-500 hover:bg-blue-600 text-white'}`}
        >
          {isUpdating || isConfirming ? 'Processing...' : 'Buy Now'}
        </button>
      )}
      {error && <p className="text-red-500 text-sm mt-1">Error: {error.message}</p>}
    </div>
  );
}

这里有个大坑:乐观更新时,你更新的状态必须与链上最终状态一致。比如购买后,NFT 的卖家会变,挂单状态会变为 false。如果只是简单地把 isActive 设为 false,但卖家地址没变,就会与链上数据不一致。最稳妥的方式是,在交易发送后,立即用事件监听来更新,或者等确认后触发一次数据重拉。

4. 页面集成与配置

最后,将服务端数据获取和客户端组件在页面中组装起来。页面是服务端组件,它获取数据并传递给客户端组件。

// app/page.tsx
import { NftList } from '@/components/nft-list';

async function getInitialNfts() {
  // 在构建时或请求时从 API 路由获取数据
  const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/nfts`, {
    // 根据需求配置缓存
    // next: { revalidate: 60 }, // ISR: 每60秒重新验证
    cache: 'no-store', // 每次请求都获取最新数据
  });
  if (!res.ok) {
    throw new Error('Failed to fetch NFTs');
  }
  return res.json();
}

export default async function HomePage() {
  const data = await getInitialNfts();

  return (
    <main className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-6">NFT Marketplace</h1>
      {/* 将服务端数据作为 prop 传递给客户端组件 */}
      <NftList initialNfts={data.nfts || []} />
    </main>
  );
}

同时,需要在项目根目录配置 wagmi 的 Provider。注意 Next.js 14 App Router 中,Provider 必须是客户端组件。

// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WagmiProvider, createConfig, http } from 'wagmi';
import { base } from 'wagmi/chains';
import { injected } from 'wagmi/connectors';

const queryClient = new QueryClient();

const config = createConfig({
  chains: [base],
  connectors: [injected()],
  transports: {
    [base.id]: http(process.env.NEXT_PUBLIC_RPC_URL),
  },
});

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </WagmiProvider>
  );
}
// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from './providers';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'NFT Marketplace',
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

完整代码结构

项目的主要文件结构如下:

my-nft-marketplace/
├── app/
│   ├── api/
│   │   └── nfts/
│   │       └── route.ts          # 服务端 API,获取初始 NFT 数据
│   ├── layout.tsx                # 根布局,包含 Providers
│   ├── page.tsx                  # 主页(服务端组件)
│   └── providers.tsx             # Wagmi & React Query Provider
├── components/
│   ├── nft-list.tsx              # NFT 列表客户端组件(核心状态与事件监听)
│   └── nft-card.tsx              # 单个 NFT 卡片组件(交易交互)
├── .env.local                    # 环境变量(合约地址、RPC URL)
└── package.json

踩坑记录

  1. NEXT_PUBLIC_ 变量在服务端为 undefined:我一开始把合约地址放在 .env.local 但没加 NEXT_PUBLIC_ 前缀,导致在服务端 API 路由中读取不到。解决:确保所有需要在浏览器和服务端共享的变量都以 NEXT_PUBLIC_ 开头。
  2. useWatchContractEvent 监听不到事件:我一开始把监听器放在 NftCard 组件里,结果组件卸载时监听器也被移除了,而且重复创建。解决:将全局事件监听提升到父组件(NftList),并确保合约地址和 ABI 正确。
  3. BigInt 序列化错误:从服务端 API 返回的数据中包含 bigint 类型的价格,直接 JSON.stringify 会报错。解决:在服务端将 bigint 转换为字符串,或者在客户端使用 Viem 的 parseEther 等工具处理。我在 API 路由中返回了原始数据,在组件内处理转换。
  4. 交易确认后状态不同步:用户购买成功后,列表里该 NFT 的 isActive 状态变了,但卖家地址还是旧的。解决:这是因为我的乐观更新逻辑不完整。最终我选择依赖 useWatchContractEvent 的事件监听作为主要更新源,手动刷新作为兜底,保证了数据与链上严格同步。

小结

这套方案的核心收获是 “服务端初始化,客户端维护动态状态,事件驱动更新”。它既保证了首屏加载速度,又实现了流畅的实时交互。对于更复杂的场景,比如分页、筛选、多链支持,还可以在此基础上扩展,例如引入状态管理库来集中管理 NFT 数据,或者用 The Graph 替代批量 RPC 调用。Web3 前端开发就是这样,每一个需求都在逼你更深入地理解数据流和链上链下的同步逻辑。

AI Mind v0.0.8:从单 Skill 到多 Skill,我如何让第二个 Skill 真正成立

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

v0.0.7 里,我已经给 AI Mind 落下了第一个正式 Skill:utility-skill

那一版的重点,是证明一件事:

在 Multi-Tool Runtime 之上,是否真的能再长出一层更稳定的能力模式。

但只有一个 Skill,其实还不够。

因为单 Skill 最多只能证明:

  • 这套结构能跑
  • Runtime 能感知 Skill
  • Tool 可以被 Skill 收口

它还证明不了另一件更关键的事:

当系统开始进入多 Skill 阶段时,这层抽象到底是不是真的成立。

所以到了 v0.0.8,我真正要回答的问题就变成了:

  1. 第二个 Skill 应该是什么
  2. 什么样的 Skill 才值得进入正式版本
  3. 多 Skill Runtime 的边界应该怎么收
  4. 前端又该怎么把这种能力模式切换表达出来

这篇文章想讲的,就是我如何从最初的 writer-skill 设想,最后收敛到了 reader-skill,并让 AI Mind 真正迈出“从单 Skill 到多 Skill”的第一步。

skill-1.gif

为什么多 Skill 是这一版必须面对的问题

如果项目一直只有一个 utility-skill,那 Skill Runtime 很容易停留在一个比较尴尬的状态:

  • 看起来像是做出了一层新抽象
  • 但又很难证明它不是一次性的特殊 case

因为只有一个 Skill 时,你很难回答这些问题:

  • Skill 之间的边界能不能真正拉开
  • Runtime 是否能根据不同 Skill 暴露不同 Tool 子集
  • 自动模式下的路由是否还有意义
  • 前端是否需要为不同 Skill 提供更明确的交互入口

换句话说,单 Skill 更像是在证明“这套机制存在”,而多 Skill 才开始证明“这套机制成立”。

所以 v0.0.8 的重点,不是再多做一个功能,而是:

让第二个 Skill 真正成为一个有独立边界、有独立 Tool 价值的能力模式。

为什么最开始想到的是 writer-skill

一开始我最自然想到的第二个 Skill,其实是 writer-skill

这个方向表面上看很合理:

  • 它和 utility-skill 差异足够大
  • 用户很容易理解“写作模式”
  • 前端做模式切换时,也很容易有感知

所以我最初尝试的方向是:

  • 做一个 writer-skill
  • 再配一个偏结构整理的 Tool
  • 让模型在“改写、总结、整理、生成标题”这类任务上走另一条路径

从想法上说,这条线没有问题。

真正的问题出在落地后。

很快我就发现,写作整理类任务和 utility-skill 最大的不同在于:

它们里有很大一部分,其实本来就是大模型原生就会做的事情。

比如:

  • 润色一段话
  • 把几句话改写得更自然
  • 整理成一段通顺表达
  • 概括几个点

这些任务里,模型往往会直接回答,而不是老老实实触发 Tool。

也就是说,writer-skill 可以命中,但 Tool 的独特价值却不够稳定。

为什么我最后放弃了 writer-skill

最后让我决定止损的,不是某一个 bug,而是一个越来越明确的判断:

第二个正式 Skill,最好补的是模型没有的能力,而不是模型已经比较擅长的能力。

writer-skill 的问题主要有三个。

1. 写作整理很多时候是模型原生能力

写作并不是不能做成 Skill,而是它很难在当前阶段承担“证明多 Skill Runtime 成立”的任务。

因为一旦用户的需求是:

  • 改写
  • 润色
  • 概括
  • 整理成更自然的一段话

模型会天然倾向于自己直接写。

这意味着:

  • Skill 也许命中了
  • 但 Tool 不一定会稳定触发

2. Tool 没形成“非它不可”的能力差

如果一个 Tool 提供的只是:

  • 换个结构
  • 换个格式
  • 帮你整理一下语序

那它很容易被模型直接绕过去。

因为模型会判断:

我自己直接写一段,往往比调用一个结构整理 Tool 更简单。

这和 calculatordatetimeunit-convert 完全不一样。

后者一旦不用 Tool,模型就很容易答错;而前者即使不用 Tool,模型也很可能还能答得不错。

3. 第二个 Skill 不应该只是“多一种说话风格”

这是这轮最重要的取舍。

我最后越来越明确地意识到:

多 Skill 的关键,不是“多几个模式名字”,也不是“多几段 Prompt”。

真正值得留下来的 Skill,应该满足至少一条:

  • 它组织了一组边界清晰的 Tool
  • 它补的是模型原本拿不到的能力
  • 它能明显改变 Runtime 的可用能力范围

writer-skill 在当前阶段没有足够强地满足这些条件。

所以我最后放弃它,并不是因为写作不重要,而是因为它不适合当前作为“第二个正式 Skill”。

为什么第二个 Skill 最终变成了 reader-skill

我最后把第二个 Skill 改成了 reader-skill

因为这时我更想验证的是:

Skill Runtime 是否能承载一类模型本身完全拿不到的信息能力。

reader-skill 对应的正是这类场景:

  • 实时天气
  • 本地文本文件

它们有一个共同点:

没有 Tool,就没有能力来源。

这和写作场景最大的区别在于:

  • 没有天气 Tool,模型就拿不到实时天气
  • 没有本地文件读取 Tool,模型就看不到你的项目文件

这时候 Tool 不再是“可选增强”,而是“能力成立的前提”。

于是 reader-skill 的价值就变得非常明确:

  • 它不是在给模型增加一种风格
  • 而是在给模型接入一类新的上下文来源

这就让第二个 Skill 终于拥有了足够清晰的独立边界。

这版 reader-skill 是怎么收边界的

reader-skill 这一版我只落了两个 Tool,而且每个 Tool 都故意收得很小。

1. city-weather

用途非常单一:

  • 查询指定城市的实时天气

它只收一个参数:

  • city

数据源我也没有做得很重,而是直接用了轻量的 wttr.in

这背后的考虑很简单:

  • 这版的重点不是做一个完整天气系统
  • 而是验证“实时信息如何进入 Skill Runtime”

也就是说,city-weather 的价值不在于“做得多强”,而在于它非常直接地证明了:

没有外部 Tool,模型就是拿不到这部分实时信息。

2. local-text-read

local-text-read 同样只做一件事:

  • 读取项目根目录下的直接文本文件

它也只收一个参数:

  • filename

而且我给它加了很强的边界限制:

  • 只允许根目录直接文件
  • 不允许子目录
  • 不允许绝对路径
  • 不允许 ../
  • 只允许文本类文件

这也是我这一版很看重的一点:

Tool 的价值不只是“能做什么”,还包括“它不会越界做什么”。

如果第二个 Skill 要证明它是一种正式能力模式,那它不仅要有能力来源,也要有稳定边界。

多 Skill Runtime 这一版真正收敛了什么

v0.0.8,项目里的 Skill 边界终于开始变清楚了。

现在可以比较明确地把它们分成两类:

utility-skill

负责确定性实用任务:

  • 计算
  • 时间日期
  • 单位换算
  • 文本转换

对应 Tool:

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

reader-skill

负责外部上下文获取:

  • 实时天气
  • 本地文件读取

对应 Tool:

  • city-weather
  • local-text-read

这时候 Skill 才真正不再只是“一个标签”,而是:

  • 当前属于哪一种能力模式
  • 当前允许模型使用哪些 Tool
  • 当前回答主要建立在哪一类能力来源上

多 Skill 链路图

flowchart LR
    A["用户请求"] --> B["/api/chat"]
    B --> C{"是否显式传入 skill"}
    C -- "是" --> D["直接命中对应 Skill"]
    C -- "否" --> E["轻量规则路由"]
    E --> F{"命中 utility / reader ?"}
    F -- "utility" --> G["utility-skill"]
    F -- "reader" --> H["reader-skill"]
    F -- "未命中" --> I["普通聊天链路"]
    G --> J["allowedTools 过滤 ToolRegistry"]
    H --> J
    J --> K["模型在当前 Tool 子集里决定是否调用 Tool"]
    K --> L["Runtime 执行 Tool"]
    L --> M["流式返回 reasoning / tool / text"]

这一版里我刻意没有做的,是:

  • 模型自主 Skill 路由
  • 多 Skill 编排
  • 更复杂的 Agent 化链路

因为我想先证明的不是“系统越来越聪明”,而是:

多 Skill Runtime 在结构上已经开始稳定成立。

为什么前端也要一起进入正式组件基线

这版还有一个我认为非常值得一起写进去的变化:

前端开始正式进入 shadcn/ui 基线阶段。

原因其实也很现实。

当输入区开始同时出现:

  • 模型选择器
  • Skill 模式切换
  • 深度思考开关
  • 推理过程面板
  • Tool 卡片

如果继续完全靠手写样式往前堆,界面会越来越像几套东西拼在一起。

所以这一版我顺手做了前端统一收口:

  • 正式接入 shadcn/ui
  • 使用 Radix
  • 图标统一为 lucide-react
  • 主题走 cssVariables
  • 当前基线切到 radix-vega

这一轮已经统一下来的区域包括:

  • 输入区控制条
  • 顶部错误条
  • 推理过程面板
  • Tool 卡片
  • 空状态

而且我还补了几项比较细的交互收口:

  • 输入框上下边距收紧
  • 推理面板上下边距收紧
  • Tool 状态色区分:
    • 完成:绿
    • 执行中:蓝
    • 失败:红
  • 实用读取 模式下的提示文案分开

这一点对我来说很重要,因为它说明:

多 Skill Runtime 的成立,不只是后端 Runtime 的问题,也是前端表达能力的一部分。

skill-2.gif

这版最重要的工程结论

如果要我用几句话总结 v0.0.8,我最想留下的是这三点:

1. 不是所有 Skill 都值得进入正式版本

有些 Skill 看起来方向对,但它不一定适合当前版本的验证目标。

writer-skill 就属于这种情况:

  • 它不是完全没价值
  • 但它不适合当前承担“证明第二个 Skill 成立”的任务

2. 第二个 Skill 最好补的是模型缺失的能力

如果 Tool 补的是模型原本就会做的事情,那它就很容易被绕过。

但如果 Tool 补的是模型完全拿不到的上下文,那 Skill 的价值会立刻清晰很多。

这也是为什么 reader-skillwriter-skill 更适合当前阶段。

3. 多 Skill 的成立,不只是 Runtime 的事,也是 UI 的事

一旦系统开始真正区分:

  • 自动 / 实用 / 读取
  • reasoning / tool / text
  • 不同 Tool 状态

那前端也必须同步给出更统一、更稳定的表达方式。

这也是为什么这版里,我没有把 UI 统一看成“顺手做的样式活”。

它其实也是版本收敛的一部分。

这一版之后,我更清楚了一件事

如果说 v0.0.7 证明的是:

Tool Runtime 之上可以长出第一层 Skill Runtime。

那么 v0.0.8 证明的就是:

多 Skill 不是多几个不同名字的 Prompt,而是第二个 Skill 是否真的打开了一块新的能力边界。

对现在的 AI Mind 来说,这块边界已经开始变得清楚:

  • utility-skill:确定性实用任务
  • reader-skill:外部上下文获取

这也让整个 Runtime Skeleton 比之前更像一个会继续长大的系统,而不是一组不断堆叠的局部功能。

后面会往哪走

如果继续沿这条线往后走,我更关心的是:

  • reader-skill 的稳定性继续收口
  • 网页读取 / MCP 能力怎么接入
  • 更高层的 Agent Runtime 什么时候开始真正有必要

但至少在 v0.0.8 这个点上,我已经比较确认:

第二个 Skill 终于不是一个“看起来像 Skill 的名字”,而是一块真正成立的能力模式。

最后

这个项目还会继续沿着:

  • reader-skill 稳定性收口
  • 网页读取 / MCP
  • Agent Runtime

这些方向继续往前走。

如果这篇文章对你有帮助,欢迎到 GitHub 看看项目,也欢迎顺手点个 Star。

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

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

上节我们学习了布局和页面的使用,本节来聊聊链接和导航。在 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

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

上节我们搭建好了 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、图片上传所有坑

为一名前端开发者,终于完整开发并部署了一个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

还在发愁只会简单地调用 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 的

本文对应项目版本: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…

❌