从零搭建 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>,
)
这里主要做了三件事:
- 找到
index.html中 id 为root的节点; - 创建 React 根节点;
- 把顶层组件
App渲染到页面。
StrictMode 会在开发环境中帮助发现不安全的副作用等问题。开发时看到某些逻辑执行两次,不要急着删除它,应先检查副作用是否正确清理。生产构建不会因此重复渲染。
五、整理目录结构
项目变大以后,把所有代码都放在 App.tsx 中会很快失控。可以先采用下面这套结构:
src/
├─ api/ # 接口定义与请求封装
├─ assets/ # 图片、字体等静态资源
├─ components/ # 通用组件
├─ hooks/ # 自定义 Hooks
├─ layouts/ # 页面布局
├─ pages/ # 路由页面
├─ router/ # 路由配置
├─ types/ # 公共 TypeScript 类型
├─ utils/ # 无业务状态的工具函数
├─ App.tsx
├─ index.css
└─ main.tsx
目录不是越多越专业。小项目可以先保留 pages、components、api 三个目录,等职责真正出现后再拆分。
六、编写第一个带类型的组件
创建 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 }
这比同时维护 loading、error、data 三个可能互相矛盾的状态更可靠。例如,联合类型不会允许“正在加载但同时成功”这种非法组合。
八、添加页面路由
安装 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 会启用一组更严格的类型检查规则。项目早期关闭严格模式看似省事,后期再开启时往往会一次出现大量错误。
遇到类型问题时,推荐按这个顺序解决:
- 先把真实数据结构定义清楚;
- 使用联合类型表达多种合法状态;
- 对外部数据进行运行时校验;
- 确实无法预知类型时使用
unknown; - 最后才考虑局部使用
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 管理、路由鉴权和请求重试,把这套基础骨架扩展为一个完整的后台管理项目前端。