使用 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

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

(可选)您可以传入 --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 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 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:将数据写入文件

到目前为止,我们已经成功地从文件中读取了数据!我们可以使用相同的方法通过 createServerFunction 写入 jokes.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 应用

理解它们如何协同工作

让我们分析一下应用程序的不同部分如何协同工作

  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 上获取。

我们的合作伙伴
Code Rabbit
Netlify
Neon
Clerk
Convex
Sentry
Prisma
订阅 Bytes

您的每周 JavaScript 资讯。每周一免费发送给超过 10 万开发者。

Bytes

无垃圾邮件。您可以随时取消订阅。

订阅 Bytes

您的每周 JavaScript 资讯。每周一免费发送给超过 10 万开发者。

Bytes

无垃圾邮件。您可以随时取消订阅。