本教程将指导您使用 TanStack Start 构建一个完整的全栈应用。您将创建一个 DevJokes 应用,用户可以查看和添加开发者主题的笑话,展示 TanStack Start 的核心概念,包括服务端函数 (server functions)、基于文件的数据存储和 React 组件。
以下是应用的实际效果演示:
完整代码已发布在 GitHub。
首先创建一个新项目:
pnpx create-start-app devjokes
cd devjokes
pnpx create-start-app devjokes
cd devjokes
运行脚本时会询问几个配置问题,您可以选择适合的选项或直接按回车使用默认值。
可选地,您可以通过 --add-on 标志添加扩展功能,如 Shadcn、Clerk、Convex、TanStack Query 等。
完成设置后,安装依赖并启动开发服务器:
pnpm i
pnpm dev
pnpm i
pnpm dev
本项目还需要额外安装几个包:
# 安装 uuid 用于生成唯一 ID
pnpm add uuid
pnpm add -D @types/uuid
# 安装 uuid 用于生成唯一 ID
pnpm add uuid
pnpm add -D @types/uuid
此时项目结构应如下所示:
/devjokes
├── src/
│ ├── routes/
│ │ ├── __root.tsx # 根布局
│ │ ├── index.tsx # 首页
│ │ ├── demo.start.server-funcs.tsx # 示例服务端函数
│ │ └── demo.start.api-request.tsx # 示例 API 请求
│ ├── api/ # API 端点
│ ├── components/ # React 组件
│ ├── api.ts # API 处理器
│ ├── client.tsx # 客户端入口
│ ├── router.tsx # 路由配置
│ ├── routeTree.gen.ts # 生成的路由树
│ ├── ssr.tsx # 服务端渲染
│ └── styles.css # 全局样式
├── public/ # 静态资源
├── app.config.ts # TanStack Start 配置
├── package.json # 项目依赖
└── tsconfig.json # TypeScript 配置
/devjokes
├── src/
│ ├── routes/
│ │ ├── __root.tsx # 根布局
│ │ ├── index.tsx # 首页
│ │ ├── demo.start.server-funcs.tsx # 示例服务端函数
│ │ └── demo.start.api-request.tsx # 示例 API 请求
│ ├── api/ # API 端点
│ ├── components/ # React 组件
│ ├── api.ts # API 处理器
│ ├── client.tsx # 客户端入口
│ ├── router.tsx # 路由配置
│ ├── routeTree.gen.ts # 生成的路由树
│ ├── ssr.tsx # 服务端渲染
│ └── styles.css # 全局样式
├── public/ # 静态资源
├── app.config.ts # TanStack Start 配置
├── package.json # 项目依赖
└── tsconfig.json # TypeScript 配置
初次接触可能觉得复杂,以下是需要关注的核心文件:
项目启动后,您可以在 localhost:3000 访问应用,将看到 TanStack Start 的默认欢迎页:
首先为笑话创建基于文件的存储系统。
在项目根目录创建 data 文件夹和 jokes.json 文件:
mkdir -p src/data
touch src/data/jokes.json
mkdir -p src/data
touch src/data/jokes.json
添加示例笑话数据:
[
{
"id": "1",
"question": "Why don't keyboards sleep?",
"answer": "Because they have two shifts"
},
{
"id": "2",
"question": "Are you a RESTful API?",
"answer": "Because you GET my attention, PUT some love, POST the cutest smile, and DELETE my bad day"
},
{
"id": "3",
"question": "I used to know a joke about Java",
"answer": "But I ran out of memory."
},
{
"id": "4",
"question": "Why do Front-End Developers eat lunch alone?",
"answer": "Because, they don't know how to join tables."
},
{
"id": "5",
"question": "I am declaring a war.",
"answer": "var war;"
}
]
[
{
"id": "1",
"question": "Why don't keyboards sleep?",
"answer": "Because they have two shifts"
},
{
"id": "2",
"question": "Are you a RESTful API?",
"answer": "Because you GET my attention, PUT some love, POST the cutest smile, and DELETE my bad day"
},
{
"id": "3",
"question": "I used to know a joke about Java",
"answer": "But I ran out of memory."
},
{
"id": "4",
"question": "Why do Front-End Developers eat lunch alone?",
"answer": "Because, they don't know how to join tables."
},
{
"id": "5",
"question": "I am declaring a war.",
"answer": "var war;"
}
]
在 src/types/index.ts 创建类型定义文件:
// src/types/index.ts
export interface Joke {
id: string
question: string
answer: string
}
export type JokesData = Joke[]
// src/types/index.ts
export interface Joke {
id: string
question: string
answer: string
}
export type JokesData = Joke[]
创建 src/serverActions/jokesActions.ts 文件实现读写操作,使用 createServerFn:
// src/serverActions/jokesActions.ts
import { createServerFn } from '@tanstack/react-start'
import * as fs from 'node:fs'
import type { JokesData } from '../types'
const JOKES_FILE = 'src/data/jokes.json'
export const getJokes = createServerFn({ method: 'GET' }).handler(async () => {
const jokes = await fs.promises.readFile(JOKES_FILE, 'utf-8')
return JSON.parse(jokes) as JokesData
})
// src/serverActions/jokesActions.ts
import { createServerFn } from '@tanstack/react-start'
import * as fs from 'node:fs'
import type { JokesData } from '../types'
const JOKES_FILE = 'src/data/jokes.json'
export const getJokes = createServerFn({ method: 'GET' }).handler(async () => {
const jokes = await fs.promises.readFile(JOKES_FILE, 'utf-8')
return JSON.parse(jokes) as JokesData
})
这段代码使用 createServerFn 创建服务端函数读取 JSON 文件,handler 函数中使用 fs 模块读取文件。
通过 TanStack Router(已内置在 TanStack Start 中)调用服务端函数。
创建 JokesList 组件展示笑话列表:
// src/components/JokesList.tsx
import { Joke } from '../types'
interface JokesListProps {
jokes: Joke[]
}
export function JokesList({ jokes }: JokesListProps) {
if (!jokes || jokes.length === 0) {
return <p className="text-gray-500 italic">No jokes found. Add some!</p>
}
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Jokes Collection</h2>
{jokes.map((joke) => (
<div
key={joke.id}
className="bg-white p-4 rounded-lg shadow-md border border-gray-200"
>
<p className="font-bold text-lg mb-2">{joke.question}</p>
<p className="text-gray-700">{joke.answer}</p>
</div>
))}
</div>
)
}
// src/components/JokesList.tsx
import { Joke } from '../types'
interface JokesListProps {
jokes: Joke[]
}
export function JokesList({ jokes }: JokesListProps) {
if (!jokes || jokes.length === 0) {
return <p className="text-gray-500 italic">No jokes found. Add some!</p>
}
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Jokes Collection</h2>
{jokes.map((joke) => (
<div
key={joke.id}
className="bg-white p-4 rounded-lg shadow-md border border-gray-200"
>
<p className="font-bold text-lg mb-2">{joke.question}</p>
<p className="text-gray-700">{joke.answer}</p>
</div>
))}
</div>
)
}
在 App.jsx 中调用服务端函数:
// App.jsx
import { createFileRoute } from '@tanstack/react-router'
import { getJokes } from './serverActions/jokesActions'
import { JokesList } from './JokesList'
export const Route = createFileRoute('/')({
loader: async () => {
// 路由访问时加载笑话数据
return getJokes()
},
component: App,
})
const App = () => {
const jokes = Route.useLoaderData() || []
return (
<div className="p-4 flex flex-col">
<h1 className="text-2xl">DevJokes</h1>
<JokesList jokes={jokes} />
</div>
)
}
// App.jsx
import { createFileRoute } from '@tanstack/react-router'
import { getJokes } from './serverActions/jokesActions'
import { JokesList } from './JokesList'
export const Route = createFileRoute('/')({
loader: async () => {
// 路由访问时加载笑话数据
return getJokes()
},
component: App,
})
const App = () => {
const jokes = Route.useLoaderData() || []
return (
<div className="p-4 flex flex-col">
<h1 className="text-2xl">DevJokes</h1>
<JokesList jokes={jokes} />
</div>
)
}
页面加载时,jokes 将自动包含来自 jokes.json 的数据!
添加 Tailwind 样式后,应用效果如下:
目前我们已能成功读取文件!现在用相同方法通过 createServerFunction 写入 jokes.json 文件。
修改 jokes.json 文件以添加新笑话。创建另一个使用 POST 方法的服务端函数:
// src/serverActions/jokesActions.ts
import { createServerFn } from '@tanstack/react-start'
import * as fs from 'node:fs'
import { v4 as uuidv4 } from 'uuid' // 添加此导入
import type { Joke, JokesData } from '../types'
export const addJoke = createServerFn({ method: 'POST' })
.validator((data: { question: string; answer: string }) => {
// 验证输入数据
if (!data.question || !data.question.trim()) {
throw new Error('Joke question is required')
}
if (!data.answer || !data.answer.trim()) {
throw new Error('Joke answer is required')
}
return data
})
.handler(async ({ data }) => {
try {
// 从文件读取现有笑话
const jokesData = await getJokes()
// 创建带唯一 ID 的新笑话
const newJoke: Joke = {
id: uuidv4(),
question: data.question,
answer: data.answer,
}
// 将新笑话添加到列表
const updatedJokes = [...jokesData, newJoke]
// 将更新后的笑话写回文件
await fs.promises.writeFile(
JOKES_FILE,
JSON.stringify(updatedJokes, null, 2),
'utf-8',
)
return newJoke
} catch (error) {
console.error('Failed to add joke:', error)
throw new Error('Failed to add joke')
}
})
// src/serverActions/jokesActions.ts
import { createServerFn } from '@tanstack/react-start'
import * as fs from 'node:fs'
import { v4 as uuidv4 } from 'uuid' // 添加此导入
import type { Joke, JokesData } from '../types'
export const addJoke = createServerFn({ method: 'POST' })
.validator((data: { question: string; answer: string }) => {
// 验证输入数据
if (!data.question || !data.question.trim()) {
throw new Error('Joke question is required')
}
if (!data.answer || !data.answer.trim()) {
throw new Error('Joke answer is required')
}
return data
})
.handler(async ({ data }) => {
try {
// 从文件读取现有笑话
const jokesData = await getJokes()
// 创建带唯一 ID 的新笑话
const newJoke: Joke = {
id: uuidv4(),
question: data.question,
answer: data.answer,
}
// 将新笑话添加到列表
const updatedJokes = [...jokesData, newJoke]
// 将更新后的笑话写回文件
await fs.promises.writeFile(
JOKES_FILE,
JSON.stringify(updatedJokes, null, 2),
'utf-8',
)
return newJoke
} catch (error) {
console.error('Failed to add joke:', error)
throw new Error('Failed to add joke')
}
})
代码解析:
创建 JokeForm.jsx 组件:
// src/components/JokeForm.tsx
import { useState } from 'react'
import { useRouter } from '@tanstack/react-router'
import { addJoke } from '../serverActions/jokesActions'
export function JokeForm() {
const router = useRouter()
const [question, setQuestion] = useState('')
const [answer, setAnswer] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
return (
<form onSubmit={handleSubmit} className="flex flex-row gap-2 mb-6">
{error && (
<div className="bg-red-100 text-red-700 p-2 rounded mb-4">{error}</div>
)}
<div className="flex flex-col sm:flex-row gap-4 mb-8">
<input
id="question"
type="text"
placeholder="输入笑话问题"
className="w-full p-2 border rounded focus:ring focus:ring-blue-300 flex-1"
value={question}
onChange={(e) => setQuestion(e.target.value)}
required
/>
<input
id="answer"
type="text"
placeholder="输入笑话答案"
className="w-full p-2 border rounded focus:ring focus:ring-blue-300 flex-1 py-4"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
required
/>
<button
type="submit"
disabled={isSubmitting}
className="bg-blue-500 hover:bg-blue-600 text-white font-medium rounded disabled:opacity-50 px-4"
>
{isSubmitting ? '添加中...' : '添加笑话'}
</button>
</div>
</form>
)
}
// src/components/JokeForm.tsx
import { useState } from 'react'
import { useRouter } from '@tanstack/react-router'
import { addJoke } from '../serverActions/jokesActions'
export function JokeForm() {
const router = useRouter()
const [question, setQuestion] = useState('')
const [answer, setAnswer] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
return (
<form onSubmit={handleSubmit} className="flex flex-row gap-2 mb-6">
{error && (
<div className="bg-red-100 text-red-700 p-2 rounded mb-4">{error}</div>
)}
<div className="flex flex-col sm:flex-row gap-4 mb-8">
<input
id="question"
type="text"
placeholder="输入笑话问题"
className="w-full p-2 border rounded focus:ring focus:ring-blue-300 flex-1"
value={question}
onChange={(e) => setQuestion(e.target.value)}
required
/>
<input
id="answer"
type="text"
placeholder="输入笑话答案"
className="w-full p-2 border rounded focus:ring focus:ring-blue-300 flex-1 py-4"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
required
/>
<button
type="submit"
disabled={isSubmitting}
className="bg-blue-500 hover:bg-blue-600 text-white font-medium rounded disabled:opacity-50 px-4"
>
{isSubmitting ? '添加中...' : '添加笑话'}
</button>
</div>
</form>
)
}
在 handleSubmit 函数中调用 addJoke 服务端函数:
//JokeForm.tsx
import { useState } from 'react'
import { addJoke } from '../serverActions/jokesActions'
import { useRouter } from '@tanstack/react-router'
export function JokeForm() {
const router = useRouter()
const [question, setQuestion] = useState('')
const [answer, setAnswer] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async () => {
if (!question || !answer || isSubmitting) return
try {
setIsSubmitting(true)
await addJoke({
data: { question, answer },
})
// 清空表单
setQuestion('')
setAnswer('')
// 刷新数据
router.invalidate()
} catch (error) {
console.error('Failed to add joke:', error)
setError('添加笑话失败')
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-row gap-2 mb-6">
{error && (
<div className="bg-red-100 text-red-700 p-2 rounded mb-4">{error}</div>
)}
<input
type="text"
name="question"
placeholder="问题"
className="p-1 border rounded w-full"
required
onChange={(e) => setQuestion(e.target.value)}
value={question}
/>
<input
type="text"
name="answer"
placeholder="答案"
className="p-1 border rounded w-full"
required
onChange={(e) => setAnswer(e.target.value)}
value={answer}
/>
<button
className="bg-blue-500 text-white p-1 rounded hover:bg-blue-600"
disabled={isSubmitting}
>
{isSubmitting ? '添加中...' : '添加笑话'}
</button>
</form>
)
}
//JokeForm.tsx
import { useState } from 'react'
import { addJoke } from '../serverActions/jokesActions'
import { useRouter } from '@tanstack/react-router'
export function JokeForm() {
const router = useRouter()
const [question, setQuestion] = useState('')
const [answer, setAnswer] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async () => {
if (!question || !answer || isSubmitting) return
try {
setIsSubmitting(true)
await addJoke({
data: { question, answer },
})
// 清空表单
setQuestion('')
setAnswer('')
// 刷新数据
router.invalidate()
} catch (error) {
console.error('Failed to add joke:', error)
setError('添加笑话失败')
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-row gap-2 mb-6">
{error && (
<div className="bg-red-100 text-red-700 p-2 rounded mb-4">{error}</div>
)}
<input
type="text"
name="question"
placeholder="问题"
className="p-1 border rounded w-full"
required
onChange={(e) => setQuestion(e.target.value)}
value={question}
/>
<input
type="text"
name="answer"
placeholder="答案"
className="p-1 border rounded w-full"
required
onChange={(e) => setAnswer(e.target.value)}
value={answer}
/>
<button
className="bg-blue-500 text-white p-1 rounded hover:bg-blue-600"
disabled={isSubmitting}
>
{isSubmitting ? '添加中...' : '添加笑话'}
</button>
</form>
)
}
完成后 UI 效果如下:
解析应用各部分的协作关系:
服务端函数:运行在服务端处理数据操作
TanStack Router:处理路由和数据加载
React 组件:构建应用 UI
基于文件的存储:在 JSON 文件中存储笑话
用户访问首页时:
用户添加新笑话时:
实际效果演示:
构建 TanStack Start 应用时可能遇到的常见问题及解决方案:
排查步骤:
Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.