本教程将指导您使用 TanStack Start 构建一个完整的全栈应用程序。您将创建一个 DevJokes 应用,用户可以在其中查看和添加开发者主题的笑话,展示 TanStack Start 的关键概念,包括服务器函数、基于文件的数据存储和 React 组件。
以下是应用程序的演示
本教程的完整代码可在 GitHub 上获取。
首先,让我们创建一个新的 TanStack Start 项目
pnpx create-start-app devjokes
cd devjokes
pnpx create-start-app devjokes
cd devjokes
当此脚本运行时,它会询问您一些设置问题。您可以选择适合您的选项,或者直接按 Enter 键接受默认值。
(可选)您可以传入 --add-on 标志来获取 Shadcn、Clerk、Convex、TanStack Query 等选项。
设置完成后,安装依赖项并启动开发服务器
pnpm i
pnpm dev
pnpm i
pnpm dev
对于此项目,我们需要一些额外的包
# Install uuid for generating unique IDs
pnpm add uuid
pnpm add -D @types/uuid
# Install uuid for generating unique IDs
pnpm add uuid
pnpm add -D @types/uuid
此时,项目结构应如下所示 -
/devjokes
├── src/
│ ├── routes/
│ │ ├── __root.tsx # Root layout
│ │ ├── index.tsx # Home page
│ │ ├── demo.start.server-funcs.tsx # Demo server functions
│ │ └── demo.start.api-request.tsx # Demo API request
│ ├── api/ # API endpoints
│ ├── components/ # React components
│ ├── api.ts # API handler.
│ ├── client.tsx # Client entry point
│ ├── router.tsx # Router configuration
│ ├── routeTree.gen.ts # Generated route tree
│ ├── ssr.tsx # Server-side rendering
│ └── styles.css # Global styles
├── public/ # Static assets
├── app.config.ts # TanStack Start configuration
├── package.json # Project dependencies
└── tsconfig.json # TypeScript configuration
/devjokes
├── src/
│ ├── routes/
│ │ ├── __root.tsx # Root layout
│ │ ├── index.tsx # Home page
│ │ ├── demo.start.server-funcs.tsx # Demo server functions
│ │ └── demo.start.api-request.tsx # Demo API request
│ ├── api/ # API endpoints
│ ├── components/ # React components
│ ├── api.ts # API handler.
│ ├── client.tsx # Client entry point
│ ├── router.tsx # Router configuration
│ ├── routeTree.gen.ts # Generated route tree
│ ├── ssr.tsx # Server-side rendering
│ └── styles.css # Global styles
├── public/ # Static assets
├── app.config.ts # TanStack Start configuration
├── package.json # Project dependencies
└── tsconfig.json # TypeScript configuration
这个结构乍一看可能令人望而生畏,但这里有一些您需要关注的关键文件
项目设置完成后,您可以通过 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 Router 已经与 TanStack Start 一起提供!
现在让我们创建一个新组件 JokesList 来在页面上渲染笑话,并添加一点 Tailwind 样式。
// 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 中使用 TanStack Router 调用我们的服务器函数,TanStack Router 已经与 TanStack Start 一起提供!
// App.jsx
import { createFileRoute } from '@tanstack/react-router'
import { getJokes } from './serverActions/jokesActions'
import { JokesList } from './JokesList'
export const Route = createFileRoute('/')({
loader: async () => {
// Load jokes data when the route is accessed
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 () => {
// Load jokes data when the route is accessed
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' // Add this import
import type { Joke, JokesData } from '../types'
export const addJoke = createServerFn({ method: 'POST' })
.validator((data: { question: string; answer: string }) => {
// Validate input data
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 {
// Read the existing jokes from the file
const jokesData = await getJokes()
// Create a new joke with a unique ID
const newJoke: Joke = {
id: uuidv4(),
question: data.question,
answer: data.answer,
}
// Add the new joke to the list
const updatedJokes = [...jokesData, newJoke]
// Write the updated jokes back to the file
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' // Add this import
import type { Joke, JokesData } from '../types'
export const addJoke = createServerFn({ method: 'POST' })
.validator((data: { question: string; answer: string }) => {
// Validate input data
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 {
// Read the existing jokes from the file
const jokesData = await getJokes()
// Create a new joke with a unique ID
const newJoke: Joke = {
id: uuidv4(),
question: data.question,
answer: data.answer,
}
// Add the new joke to the list
const updatedJokes = [...jokesData, newJoke]
// Write the updated jokes back to the file
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="Enter joke question"
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="Enter joke answer"
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 ? 'Adding...' : 'Add Joke'}
</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="Enter joke question"
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="Enter joke answer"
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 ? 'Adding...' : 'Add Joke'}
</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 },
})
// Clear form
setQuestion('')
setAnswer('')
// Refresh data
router.invalidate()
} catch (error) {
console.error('Failed to add joke:', error)
setError('Failed to add joke')
} 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="Question"
className="p-1 border rounded w-full"
required
onChange={(e) => setQuestion(e.target.value)}
value={question}
/>
<input
type="text"
name="answer"
placeholder="Answer"
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 ? 'Adding...' : 'Add Joke'}
</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 },
})
// Clear form
setQuestion('')
setAnswer('')
// Refresh data
router.invalidate()
} catch (error) {
console.error('Failed to add joke:', error)
setError('Failed to add joke')
} 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="Question"
className="p-1 border rounded w-full"
required
onChange={(e) => setQuestion(e.target.value)}
value={question}
/>
<input
type="text"
name="answer"
placeholder="Answer"
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 ? 'Adding...' : 'Add Joke'}
</button>
</form>
)
}
至此,我们的 UI 应该如下图所示:
让我们分析一下应用程序的不同部分如何协同工作
服务器函数:这些在服务器上运行并处理数据操作
TanStack Router:处理路由和数据加载
React 组件:构建我们应用程序的 UI
基于文件的存储:将我们的笑话存储在 JSON 文件中
当用户访问主页时
当用户添加新笑话时
以下是应用程序的演示
以下是您在构建 TanStack Start 应用程序时可能遇到的一些常见问题以及如何解决这些问题
如果您的服务器函数未按预期工作
如果路由数据未正确加载
如果表单提交不工作
使用基于文件存储时
恭喜!您已经使用 TanStack Start 构建了一个全栈 DevJokes 应用程序。在本教程中,您已经学到了
这个简单的应用程序展示了 TanStack Start 在以最少代码量构建全栈应用程序方面的强大功能。您可以通过添加以下功能来扩展此应用程序:
本教程的完整代码可在 GitHub 上获取。
您的每周 JavaScript 资讯。每周一免费发送给超过 10 万开发者。