使用 TanStack Start 构建全栈 DevJokes 应用

本教程将指导您使用 TanStack Start 构建一个完整的全栈应用程序。您将创建一个 DevJokes 应用,用户可以查看和添加开发者主题的笑话,演示 TanStack Start 的关键概念,包括服务器函数、基于文件的数据存储和 React 组件。

这是应用程序运行的演示:

本教程的完整代码可在 GitHub 上获得。

您将学到什么

  1. 设置 TanStack Start 项目
  2. 实现服务器函数
  3. 读取和写入文件数据
  4. 使用 React 组件构建完整的 UI
  5. 使用 TanStack Router 进行数据获取和导航

先决条件

  • React 和 TypeScript 的基础知识。
  • 在您的机器上安装了 Node.js 和 pnpm

有用的背景知识

设置 TanStack Start 项目

首先,让我们创建一个新的 TanStack Start 项目:

bash
pnpx create-start-app devjokes
cd devjokes
pnpx create-start-app devjokes
cd devjokes

当此脚本运行时,它会询问您一些设置问题。您可以选择适合您的选项,或者只需按回车键接受默认设置。

可选地,您可以传入 --add-on 标志来获取诸如 Shadcn、Clerk、Convex、TanStack Query 等选项。

设置完成后,安装依赖项并启动开发服务器:

bash
pnpm i
pnpm dev
pnpm i
pnpm dev

对于这个项目,我们需要一些额外的包:

bash
# 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

这个结构起初可能看起来令人不知所措,但以下是您需要关注的关键文件:

  1. router.tsx - 为您的应用程序设置路由
  2. src/routes/__root.tsx - 根布局组件,您可以在其中添加全局样式和组件
  3. src/routes/index.tsx - 您的主页
  4. client.tsx - 客户端入口点
  5. ssr.tsx - 处理服务器端渲染

设置项目后,您可以在 localhost:3000 访问您的应用程序。您应该看到默认的 TanStack Start 欢迎页面。

此时,您的应用程序将如下所示 -

TanStack Start Welcome Page After Setup

步骤 1:从文件读取数据

让我们首先为我们的笑话创建一个基于文件的存储系统。

步骤 1.1:创建包含笑话的 JSON 文件

让我们设置一个笑话列表,我们可以用它在页面上渲染。在项目根目录中创建一个 data 目录,并在其中创建一个 jokes.json 文件:

bash
mkdir -p src/data
touch src/data/jokes.json
mkdir -p src/data
touch src/data/jokes.json

现在,让我们向此文件添加一些示例笑话:

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;"
  }
]

步骤 1.2:为我们的数据创建类型

让我们创建一个文件来定义我们的数据类型。在 src/types/index.ts 创建一个新文件:

typescript
// 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[]

步骤 1.3:创建服务器函数来读取文件

让我们创建一个新文件 src/serverActions/jokesActions.ts 来创建一个服务器函数来执行读写操作。我们将使用 createServerFn 创建一个服务器函数。

tsx
// 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 模块读取文件的地方。

步骤 1.4:在客户端使用服务器函数

现在要使用这个服务器函数,我们可以简单地在代码中使用 TanStack Router 调用它,它已经包含在 TanStack Start 中!

现在让我们创建一个新组件 JokesList 来在页面上渲染笑话,并添加一些 Tailwind 样式。

tsx
// 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 Start 中!

jsx
// 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 样式,应用程序应该如下所示:

DevJoke App with 5 DevJokes

步骤 2:向文件写入数据

到目前为止,我们已经能够成功地从文件中读取数据!我们可以使用相同的方法使用 createServerFunctionjokes.json 文件写入数据。

步骤 2.1:创建服务器函数来写入文件

现在是时候修改 jokes.json 文件,以便我们可以向其中添加新笑话。让我们创建另一个服务器函数,但这次使用 POST 方法写入同一个文件。

tsx
// 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')
    }
  })

在这段代码中:

  • 我们使用 createServerFn 创建在服务器上运行但可以从客户端调用的服务器函数。此服务器函数用于向文件写入数据。
  • 我们首先使用 validator 来验证输入数据。这是确保我们接收的数据格式正确的良��实践。
  • 我们将在 handler 函数中执行实际的写入操作。
  • getJokes 从我们的 JSON 文件中读取笑话。
  • addJoke 验证输入数据并向我们的文件添加新笑话。
  • 我们使用 uuidv4() 为我们的笑话生成唯一 ID。

步骤 2.2:添加表单以向我们的 JSON 文件添加笑话

现在,让我们修改主页以显示笑话并提供添加新笑话的表单。让我们创建一个名为 JokeForm.jsx 的新组件,并向其中添加以下表单:

tsx
// 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>
  )
}

步骤 2.3:将表单连接到服务器函数

现在,让我们在 handleSubmit 函数中将表单连接到我们的 addJoke 服务器函数。调用服务器操作很简单!它只是一个函数调用。

tsx
//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 应该如下所示: DevJoke App with Form to Add Jokes

理解一切如何协同工作

让我们分解应用程序的不同部分如何协同工作:

  1. 服务器函数:这些在服务器上运行并处理数据操作

    • getJokes:从我们的 JSON 文件中读取笑话
    • addJoke:向我们的 JSON 文件添加新笑话
  2. TanStack Router:处理路由和数据加载

    • loader 函数在访问路由时获取笑话数据
    • useLoaderData 使这些数据在我们的组件中可用
    • router.invalidate() 在我们添加新笑话时刷新数据
  3. React 组件:构建我们应用程序的 UI

    • JokesList:显示笑话列表
    • JokeForm:提供添加新笑话的表单
  4. 基于文件的存储:将我们的笑话存储在 JSON 文件中

    • 读取和写入由 Node.js fs 模块处理
    • 数据在服务器重启之间持久化

数据如何在应用程序中流动

数据流

Data Flow Diagram

当用户访问主页时:

  1. 路由中的 loader 函数调用 getJokes() 服务器函数
  2. 服务器读取 jokes.json 并返回笑话数据
  3. 这些数据通过 useLoaderData() 传递给 HomePage 组件
  4. HomePage 组件将数据传递给 JokesList 组件

当用户添加新笑话时:

  1. 他们填写表单并��交
  2. handleSubmit 函数调用 addJoke() 服务器函数
  3. 服务器读取当前笑话,添加新笑话,并将更新的数据写回 jokes.json
  4. 操作完成后,我们调用 router.invalidate() 来刷新数据
  5. 这会再次触发 loader,获取更新的笑话
  6. UI 更新以在列表中显示新笑话

这是应用程序运行的演示:

常见问题和调试

以下是您在构建 TanStack Start 应用程序时可能遇到的一些常见问题以及如何解决它们:

服务器函数不工作

如果您的服务器函数没有按预期工作:

  1. 检查您是否使用了正确的 HTTP 方法(GETPOST 等)
  2. 确保文件路径正确且服务器可以访问
  3. 检查服务器控制台的错误消息
  4. 确保您没有在服务器函数中使用仅限客户端的 API

路由数据未加载

如果路由数据没有正确加载:

  1. 验证您的 loader 函数是否正确实现
  2. 检查您是否正确使用了 useLoaderData()
  3. 查看浏览器控制台中的错误
  4. 确保您的服务器函数工作正常

表单提交问题

如果表单提交不工作:

  1. 检查服务器函数中的验证错误
  2. 验证表单事件阻止(e.preventDefault())是否工作
  3. 确保状态更新正确进行
  4. 在浏览器的开发者工具中查找网络错误

文件读取/写入问题

在使用基于文件的存储时:

  1. 确保文件路径正确
  2. 检查文件权限
  3. 确保您使用 await 正确处理异步操作
  4. 为文件操作添加适当的错误处理

结论

恭喜!您已经使用 TanStack Start 构建了一个全栈 DevJokes 应用。在本教程中,您学到了:

  • 如何设置 TanStack Start 项目
  • 如何实现用于数据操作的服务器函数
  • 如何读取和写入文件数据
  • 如何为您的 UI 构建 React 组件
  • 如何使用 TanStack Router 进行路由和数据获取

这个简单的应用程序展示了 TanStack Start 用最少的代码构建全栈应用程序的强大功能。您可以通过添加以下功能来扩展此应用:

  • 笑话分类
  • 编辑和删除笑话的能力
  • 用户身份验证
  • 为最喜欢的笑话投票

本教程的完整代码可在 GitHub 上获得。