可用于生产的 React 基线:每个包的实际功能是什么
为 React 应用安装 Vite、Tailwind v4、Redux Toolkit、React Router、Jest 和 Prettier,并了解每个插件及配置项的存在原因。
运行 npm create vite 可以生成一个能够渲染的 React 应用,但它并非可直接交付给真实用户的版本:没有样式系统、没有共享状态、没有路由功能、没有测试用例,也没有统一的代码格式。本指南将逐步利用 Tailwind CSS、Redux Toolkit、React Router、Jest 配合 React Testing Library 以及 Prettier 来构建这些缺失的基础组件。对于每一个工具包,都会解答两个问题:它的实际功能是什么?如果省略它会导致什么问题?完成学习后,你将拥有一个可用于开发功能的可用基础框架,更重要的是,你能够理解自己的 package.json 文件并解释其中的每一行代码。
技术栈概览:
- Tailwind CSS:用于样式设计
- Redux Toolkit:用于管理应用共享数据
- React Router:用于页面导航
其中一些工具只需一行命令即可安装。另一些则隐藏着令人意想不到的细节;例如,“React Testing Library”实际上是由三个承担不同功能的包组成的。
建议使用 React + TypeScript 模板创建一个全新的 Vite 项目:
npm create vite@latest react-production-stack -- --template react-ts
cd react-production-stack
npm install
Tailwind CSS:先集成样式功能
所需包: tailwindcss、@tailwindcss/vite
由于样式会影响到每个组件,因此在添加其他内容之前先确认其功能正常是很重要的。
npm install tailwindcss @tailwindcss/vite
这样会将这两个包作为普通依赖而非开发依赖进行安装。严格来说,这两个包都不会在浏览器中运行:Vite 插件仅在构建时执行任务,最终只有生成的 CSS 会进入生产环境打包文件。因此许多团队会将它们归类为开发依赖,而对于打包后的单页应用来说,无论选择哪种方式都会得到相同的输出。请选定一种规范并始终如一地遵循。
接下来,在 Vite 配置中与 React 插件一同注册该插件:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
})
然后用一个导入语句替换 src/index.css 文件中的内容。整个文件的内容如下:
/* Tailwind v4 is CSS-first. No config file, no content globs. */
@import 'tailwindcss';
实际上设置就这些了。Tailwind v4 采用 CSS 首先的架构:没有 tailwind.config.js 文件,也没有内容匹配列表,因为它会自动扫描源文件中的类名。
验证其功能
在 App.tsx 中的标题上暂时添加一些实用类,例如 text-3xl font-bold text-blue-600,然后运行 npm run dev,查看标题是否发生变化。如果发生了变化,说明插件与 CSS 导入已成功关联。
为何选择实用类而非独立的样式表
Tailwind 将样式直接放在其影响的标记上。使用独立的 CSS 文件时,很容易在编辑组件时忘记对应的样式表,从而导致大量过时且无用的规则逐渐积累。尤其是控制面板,会多次重复使用相同的组件元素(卡片、徽章、按钮),通过一套通用的实用类来组合这些元素,既能保持视觉一致性,又能减少维护代码量。相应的代价是需要改变习惯:不再自行创建诸如 .card-header-active 这样的类名,而是用预先定义的小类来组合每个元素。
Redux Toolkit:状态存储与 React 的桥梁
相关包: @reduxjs/toolkit、react-redux
这两个包很容易被混淆,但它们的功能各不相同:
@reduxjs/toolkit就是状态存储本身:它负责保存应用程序数据并对其进行更新。react-redux则是与 React 的连接方式:它提供了<Provider>以及钩子组件,用于读取和更新这些数据。
两者都是必需的,因为单独使用任何一个都无法完成另一项功能。
npm install @reduxjs/toolkit react-redux
在 src/app/store.ts 中创建状态存储。该文件以一个空的 reducer 映射表开始,并导出两种从状态存储派生出的类型,这样应用程序的其他部分就无需手动定义它们了:
import { configureStore } from '@reduxjs/toolkit'
export const store = configureStore({
reducer: {},
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
目前 reducer: {} 对象仍是空的。只有当真正需要功能时,比如处理项目或任务时,才会添加相关切片;在有任何界面使用之前就创建状态并无意义。
接着在 src/app/hooks.ts 中定义类型化的钩子函数。近期版本的 React Redux 提供的 withTypes 工具可以一次性将 useDispatch 和 useSelector 与存储的类型绑定,这样组件就能自动获得完整的类型推断,无需为每次调用都手动添加注解:
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './store'
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
将存储传递给组件树
此时存储已经存在,但 React 还不知道它的存在。<Provider> 可以让其被下层的所有组件使用,因此需要将其放在 src/main.tsx 中的组件树最顶层:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import { store } from './app/store'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider store={store}>
<App />
</Provider>
</StrictMode>,
)
现在,<Provider>内部渲染的任何内容都可以调用useAppSelector和useAppDispatch。
验证其功能
启动应用,确认页面仍能正常渲染,不会出现“无法找到react-redux上下文”的错误。只要组件在Provider之外使用Redux钩子,就会出现该错误。由于存储为空,目前还没有其他内容可以测试。
何时该使用Redux,何时仅用useState就足够
并非所有内容都适合放入Redux中,将所有状态都存入存储与将所有状态保留在本地一样是错误的。一个实用的准则是:
useState适用于仅对单个屏幕或组件重要的数据:比如模态框是否打开、输入框的当前值、下拉菜单中选中的选项等。
如果某个状态需要通过多层传递或在不同屏幕间重复使用,那它就很适合存放在存储中。
React Router:在第一个实际页面之前进行路由配置
包名: react-router
在还没有实际页面时就添加路由配置似乎为时过早,但实际上很快就能看到好处:每个新屏幕只需新增一个<Route>,而无需日后再对应用结构进行大规模调整。
npm install react-router
将路由表放在独立的模块中,即 src/routes/AppRoutes.tsx。目前它将 / 映射到使用 Tailwind 工具类进行样式的占位组件:
import { Route, Routes } from 'react-router'
function Placeholder() {
return (
<div className="flex min-h-screen items-center justify-center">
<p className="text-slate-600">Routes coming soon</p>
</div>
)
}
export function AppRoutes() {
return (
<Routes>
<Route path="/" element={<Placeholder />} />
</Routes>
)
}
src/App.tsx 会直接渲染该路由表:
import { AppRoutes } from './routes/AppRoutes'
function App() {
return <AppRoutes />
}
export default App
最后,在 src/main.tsx 中,将 Redux 提供器与 <BrowserRouter> 放在一起包裹整个应用:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import { BrowserRouter } from 'react-router'
import { store } from './app/store'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
</StrictMode>,
)
最终的调用链为 main.tsx → <App /> → <AppRoutes /> → 与 URL 匹配的任意 <Route>。Redux 和路由器是相互独立的,因此它们的嵌套顺序并不重要;唯一的要求是两者都必须包裹 <App>。
验证其功能正常
运行 npm run dev 并打开 / 页面。如果出现了占位文本 <BrowserRouter>、<Routes> 和 <Route>,说明它们的连接都是正确的。
Jest 与 React Testing Library:四项任务,十一个包
相关包: jest、@testing-library/react、babel-jest 以及其他几个包
这是耗时最长的步骤。相关概念本身并不复杂,但“添加测试”实际上意味着需要安装大约十一个包来承担四项不同的功能,然后还要在路由配置完成后让某个测试通过。将这些包按任务分类后,整个流程会清晰许多。
A组:测试运行器与模拟浏览器
npm install -D jest jest-environment-jsdom
- jest是测试运行器。它会查找
*.test.tsx文件,执行这些文件并报告测试通过与失败的情况。没有它,本节中的其他功能都无法正常工作。 - 需要jest-environment-jsdom是因为Jest在Node环境中运行,而该环境中不存在
document对象。它提供了模拟的DOM环境,以便组件有地方进行渲染。
B组:React Testing Library由三个包组成
npm install -D @testing-library/react
npm install -D @testing-library/jest-dom
npm install -D @testing-library/user-event
人们所说的“React Testing Library”实际上是由三个独立的库构成的,每个库都有其特定的功能:
- @testing-library/react负责将组件渲染到模拟的页面中,并提供诸如
screen.getByText(...)之类的查询方法。
toBeInTheDocument() 这样易于理解的匹配器,因此无需手动将查询结果与 null 进行比较。简而言之:渲染、断言、交互。三项功能,三个插件,而且几乎总需要全部使用。
C组:让Jest能够读取TSX文件的Babel工具链
设立这一组的唯一原因是:Jest本身无法理解TypeScript或JSX文件。
- babel-jest 负责连接二者。Jest会在执行文件前先将其通过Babel处理。
: string 及类似的语法。与 B 组不同,这些预设需要通过一个命令一起安装。所有预设都要求有兼容的 @babel/core,如果在已经包含 Jest(它会自带 Babel 依赖)的项目中逐个安装,npm 就可能试图协调不匹配的版本。常见的现象是在下次单独安装某个包时出现 ERESOLVE unable to resolve dependency tree 错误。一次性安装整个组可以让 npm 解决版本一致性问题。
另一个更为隐蔽的陷阱来自从PDF或网页中复制长命令。软换行的文本在粘贴后会变成真正的换行符,因此像 @babel/preset-typescript 这样的包名会被拆分成两部分,shell会将后半部分作为独立的、无意义的命令来执行。而显式的换行则能让换行符出现在你期望的位置。以下示例使用的是Windows命令提示符语法:
npm install -D babel-jest ^
@babel/core ^
@babel/preset-env ^
@babel/preset-react ^
@babel/preset-typescript
末尾的 ^ 表示命令会在下一行继续。在PowerShell中,换行符是反引号,而在bash或zsh中则是反斜杠。无论使用哪种shell,这仍然只是一个 npm install 命令。
D组:仅供编辑器使用的类型
npm install -D @types/jest
该包不会影响测试的运行方式;因为到那时 Babel 已经移除了所有类型信息。它的存在是为了让 TypeScript 和编辑器能够识别 test(...) 和 expect(...) 这样的全局变量,而不会将其标记为错误。
添加测试脚本
安装 Jest 后并不会自动生成 npm test 命令,因此需要手动在 package.json 中添加相关脚本:
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"test": "jest",
"test:watch": "jest --watch"
}
npm test 会一次性运行全部测试用例。npm run test:watch 则会持续运行,仅重新执行你刚保存的文件所涉及的测试用例;工作时可在另一个终端中保持该命令处于运行状态。
当出现问题时,还有两个命令值得记住:
npx jest src/App.test.tsx # run one file only
npx jest --clearCache # when Jest keeps showing an error
# you already fixed
缓存命令的重要性远超表面所见。Jest会缓存处理后的文件,因此当你修改了babel.config.cjs或jest.config.cjs后,它仍可能继续提供旧的输出结果,并报出你已经解决的错误。如果修复似乎不起作用,先清除缓存,再判断该修复是否确实有误。
完整的测试配置
以下是所有配置文件的完整内容,同时说明了每个部分的职责。
babel.config.cjs
这些预设与C组相同:针对当前Node版本,使用自动JSX运行时以便文件无需导入React,并移除TypeScript。内联插件能处理Jest无法处理的内容:import.meta,Vite代码会用它来实现诸如import.meta.env和热模块替换等功能,但在Jest运行的CommonJS输出环境中该语法无效。
function stripImportMeta() {
return {
visitor: {
MetaProperty(path) {
path.replaceWithSourceString('({ url: "", hot: undefined })')
},
},
}
}
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { runtime: 'automatic' }],
'@babel/preset-typescript',
],
plugins: [stripImportMeta],
}
这是一个真正的 Babel 插件,以内联函数的形式实现而非作为已安装的包;Babel 可以接受这两种形式。MetaProperty 是 Babel 用于表示 import.meta 的 AST 节点类型,该插件会将所有 MetaProperty 实例替换为普通的对象,这类对象的 url 属性为空,hot 属性未定义。需要注意的是,这也会隐藏被测试代码中的任何 import.meta.env 值,因此需要读取环境变量的组件必须单独进行模拟。
jest.config.cjs
该文件将运行器与其他所有组件关联起来。它选择 jsdom 环境,在环境准备就绪后加载配置文件,将所有的 JavaScript 和 TypeScript 文件通过 babel-jest 处理,并将样式和图片导入映射为虚拟模块。
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'mjs', 'json'],
transform: {
'^.+\\.(ts|tsx|js|jsx|mjs)