从零搭建 React + TypeScript 项目:目录、路由、请求封装与工程化配置

4 阅读7分钟

从零搭建 React + TypeScript 项目:一套能真正用于开发的工程结构

本文面向掌握 HTML、CSS 和 JavaScript 基础,希望开始使用 React + TypeScript 的开发者。我们将从空目录出发,搭建一个包含路由、类型设计、环境变量、请求封装和基础工程规范的现代前端项目。

很多 React 入门教程在页面显示出一句“Hello React”后就结束了。但真正开始写业务时,我们马上会遇到更多问题:页面放在哪里?接口怎么封装?路由如何组织?环境变量怎样区分?TypeScript 类型又该写在哪?

这篇文章不追求堆砌依赖,而是搭建一套小而完整、后续容易扩展的项目骨架。

一、技术栈

本文采用以下组合:

  • React:负责构建组件化界面
  • TypeScript:在编译阶段发现类型问题
  • Vite:提供开发服务器和生产构建
  • React Router:实现单页应用路由
  • 原生 Fetch API:完成请求封装,减少不必要依赖

React 官方建议新应用优先考虑合适的框架;如果项目不需要服务端渲染,或者希望学习和控制基础工程结构,也可以使用 Vite 从零构建客户端应用。

二、准备开发环境

先确认本机 Node.js 版本:

node -v
npm -v

当前 Vite 官方文档要求 Node.js 20.19+ 或 22.12+。实际开发中建议直接选择 Node.js 的 LTS 版本,避免使用生命周期较短的奇数版本。

三、创建 React + TypeScript 项目

执行:

npm create vite@latest react-ts-app -- --template react-ts
cd react-ts-app
npm install
npm run dev

浏览器打开终端提示的地址,通常是:

http://localhost:5173

至此,一个基础项目已经运行起来。

Vite 默认提供三个常用命令:

npm run dev      # 启动开发服务器
npm run build    # 类型检查并生成生产文件
npm run preview  # 本地预览生产构建结果

四、认识入口文件

React 应用的启动入口通常是 src/main.tsx

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import './index.css'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

这里主要做了三件事:

  1. 找到 index.html 中 id 为 root 的节点;
  2. 创建 React 根节点;
  3. 把顶层组件 App 渲染到页面。

StrictMode 会在开发环境中帮助发现不安全的副作用等问题。开发时看到某些逻辑执行两次,不要急着删除它,应先检查副作用是否正确清理。生产构建不会因此重复渲染。

五、整理目录结构

项目变大以后,把所有代码都放在 App.tsx 中会很快失控。可以先采用下面这套结构:

src/
├─ api/             # 接口定义与请求封装
├─ assets/          # 图片、字体等静态资源
├─ components/      # 通用组件
├─ hooks/           # 自定义 Hooks
├─ layouts/         # 页面布局
├─ pages/           # 路由页面
├─ router/          # 路由配置
├─ types/           # 公共 TypeScript 类型
├─ utils/           # 无业务状态的工具函数
├─ App.tsx
├─ index.css
└─ main.tsx

目录不是越多越专业。小项目可以先保留 pagescomponentsapi 三个目录,等职责真正出现后再拆分。

六、编写第一个带类型的组件

创建 src/components/UserCard.tsx

type UserCardProps = {
  name: string
  age?: number
  onView: (name: string) => void
}

export function UserCard({ name, age, onView }: UserCardProps) {
  return (
    <article className="user-card">
      <h2>{name}</h2>
      <p>{age === undefined ? '年龄保密' : `${age} 岁`}</p>
      <button type="button" onClick={() => onView(name)}>
        查看详情
      </button>
    </article>
  )
}

这里的 UserCardProps 明确表达了组件契约:

  • name 必须传入,并且必须是字符串;
  • age 后面的 ? 表示它可选;
  • onView 必须是接收字符串、无返回值要求的函数。

这样做不仅可以提前发现错误,还能让编辑器自动补全组件参数。相比到处使用 any,清晰的类型本身就是文档。

七、正确给状态建模

简单状态通常可以依靠 TypeScript 自动推断:

const [count, setCount] = useState(0)

此时 count 会被推断为 number。但初始值不足以表达完整类型时,应显式声明:

type User = {
  id: number
  name: string
  email: string
}

const [user, setUser] = useState<User | null>(null)

请求状态则适合使用联合类型:

type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; message: string }

这比同时维护 loadingerrordata 三个可能互相矛盾的状态更可靠。例如,联合类型不会允许“正在加载但同时成功”这种非法组合。

八、添加页面路由

安装 React Router:

npm install react-router

创建两个页面:

// src/pages/HomePage.tsx
export function HomePage() {
  return <h1>首页</h1>
}
// src/pages/AboutPage.tsx
export function AboutPage() {
  return <h1>关于我们</h1>
}

修改 src/main.tsx,在应用外层添加 BrowserRouter

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import App from './App.tsx'
import './index.css'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>,
)

然后配置 App.tsx

import { Link, Route, Routes } from 'react-router'
import { AboutPage } from './pages/AboutPage'
import { HomePage } from './pages/HomePage'

export default function App() {
  return (
    <>
      <header>
        <nav>
          <Link to="/">首页</Link>
          {' | '}
          <Link to="/about">关于</Link>
        </nav>
      </header>

      <main>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/about" element={<AboutPage />} />
          <Route path="*" element={<h1>404:页面不存在</h1>} />
        </Routes>
      </main>
    </>
  )
}

注意,新版 React Router 的声明式安装文档使用 react-router。不要机械照抄多年以前的教程,否则很容易混用不同版本的 API。

九、封装一个类型安全的请求函数

创建 src/api/http.ts

const API_BASE_URL = import.meta.env.VITE_API_BASE_URL

type ApiErrorBody = {
  message?: string
}

export async function request<T>(
  path: string,
  options: RequestInit = {},
): Promise<T> {
  const response = await fetch(`${API_BASE_URL}${path}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...options.headers,
    },
  })

  if (!response.ok) {
    const error = (await response.json().catch(() => ({}))) as ApiErrorBody
    throw new Error(error.message ?? `请求失败:${response.status}`)
  }

  return response.json() as Promise<T>
}

再创建 src/api/users.ts

import { request } from './http'

export type User = {
  id: number
  name: string
  email: string
}

export function getUsers() {
  return request<User[]>('/users')
}

调用时,返回值会自动成为 Promise<User[]>

import { useEffect, useState } from 'react'
import { getUsers, type User } from '../api/users'

export function UsersPage() {
  const [users, setUsers] = useState<User[]>([])
  const [error, setError] = useState('')

  useEffect(() => {
    getUsers()
      .then(setUsers)
      .catch((reason: unknown) => {
        setError(reason instanceof Error ? reason.message : '未知错误')
      })
  }, [])

  if (error) return <p role="alert">{error}</p>

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

这里有一个很重要的边界:TypeScript 只能检查编译时类型,不能保证服务器实际返回的数据一定符合 User。对支付、权限等关键数据,应在运行时增加数据校验。

十、配置环境变量

在项目根目录创建 .env.development

VITE_API_BASE_URL=http://localhost:3000/api

创建 .env.production

VITE_API_BASE_URL=https://api.example.com

Vite 只会把以 VITE_ 开头的变量暴露给前端代码。但“能否暴露”不等于“可以存秘密”:前端构建产物最终会发送到浏览器,因此 API 私钥、数据库密码等敏感信息绝不能写入前端环境变量。

为环境变量补充类型,新建 src/vite-env.d.ts

/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_BASE_URL: string
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

十一、配置路径别名

随着目录层级增加,下面这种导入会越来越难维护:

import { request } from '../../../api/http'

我们希望改成:

import { request } from '@/api/http'

修改 vite.config.ts

import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
})

同时在 TypeScript 配置中加入:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

Vite 和 TypeScript 都要配置:前者负责运行和构建时解析,后者负责类型检查和编辑器提示。

十二、保持 TypeScript 严格模式

Vite 模板默认已经提供较严格的 TypeScript 配置。建议保留 strict: true

{
  "compilerOptions": {
    "strict": true
  }
}

strict 会启用一组更严格的类型检查规则。项目早期关闭严格模式看似省事,后期再开启时往往会一次出现大量错误。

遇到类型问题时,推荐按这个顺序解决:

  1. 先把真实数据结构定义清楚;
  2. 使用联合类型表达多种合法状态;
  3. 对外部数据进行运行时校验;
  4. 确实无法预知类型时使用 unknown
  5. 最后才考虑局部使用 any

十三、构建与部署前检查

开发完成后执行:

npm run build

构建结果会输出到 dist 目录。再执行:

npm run preview

这一步用于本地预览生产构建,但它不是正式生产服务器。

如果应用使用 BrowserRouter,部署服务器需要把未知路径回退到 index.html。否则直接访问 /about 时,服务器可能返回 404。这不是 React Router 配置错误,而是服务器没有把该地址交给前端路由处理。

十四、常见误区

1. 所有状态都放进全局状态库

输入框内容、弹窗开关等只被局部组件使用的状态,应优先放在组件内部。只有跨页面共享、生命周期较长的状态才值得提升或集中管理。

2. 为每个变量手写类型

TypeScript 的类型推断非常强。useState(false) 已经能推断出布尔类型,不需要为了“看起来类型化”重复标注。

3. 使用 any 快速消除报错

any 会关闭这部分代码的类型检查。面对接口数据时,unknown 更安全,因为你必须先判断类型才能使用。

4. 把密钥写入 .env

浏览器端环境变量会进入构建产物。即使文件没有提交到 Git,用户仍可能在浏览器中看到它。

5. 一开始就设计过度复杂的目录

目录应该服务于职责边界,而不是制造仪式感。先让代码工作,再根据变化频率和复用关系逐步拆分。

总结

到这里,我们已经完成了一套能够继续承载业务的 React + TypeScript 项目骨架:

  • 使用 Vite 创建和构建项目;
  • 使用 TypeScript 描述组件、状态和接口数据;
  • 使用 React Router 管理页面;
  • 使用泛型封装 Fetch 请求;
  • 使用环境变量区分不同运行环境;
  • 使用路径别名改善模块导入;
  • 使用严格模式尽早发现问题。

真正有价值的工程化不是安装尽可能多的库,而是让代码边界更清楚、错误更早暴露、项目更容易演进。

下一篇可以继续完成登录页、Token 管理、路由鉴权和请求重试,把这套基础骨架扩展为一个完整的后台管理项目前端。

参考资料