别再用 useEffect 写满组件了!试试这个三层数据架构 🤔🤔🤔
面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系:
yunmz777
。
我们常常低估了数据获取的复杂性,直到项目已经陷入困境。很多项目一开始只是简单地在组件中随手使用 useEffect()
和 fetch()
。
然而不知不觉中,错误处理、加载状态、缓存逻辑和重复请求越堆越多,代码变得混乱难以维护,调试也越来越痛苦。
以下是我在许多项目中常见的一些问题:
- 组件触发了重复的网络请求 —— 只是因为没有正确缓存数据
- 组件频繁重渲染 —— 状态管理混乱,每秒更新几十次
- 过多的骨架屏加载效果 —— 导致整个应用看起来总是“卡在加载中”
- 用户看到旧数据 —— 修改数据后缓存没有及时更新
- 并发请求出现竞态条件 —— 不同请求返回顺序无法预测,导致数据错乱
- 内存泄漏 —— 订阅和事件监听未正确清理
- 乐观更新失败却悄无声息 —— 页面展示和实际数据不一致
- 服务端渲染数据失效太快 —— 页面跳转后立即变成过期数据
- 无效的轮询逻辑 —— 要么频繁请求没有变化的数据,要么根本没必要轮询
- 组件与数据请求逻辑强耦合 —— 导致组件复用性极差
- 顺序依赖的多层请求链 —— 比如:获取用户 → 用户所属组织 → 组织下的团队 → 团队成员
以上问题在复杂应用中极为常见。如果不从架构层面进行规划和优化,很容易陷入混乱的技术债中,影响项目长期维护与扩展。
这些问题会互相叠加。一个糟糕的数据获取模式,往往会引发三个新的问题。等你意识到时,原本“简单”的仪表盘页面已经需要从头重构了。
这篇文章将向你展示一种更好的做法,至少是我在项目中偏好的架构方式。我们将构建一个三层数据获取架构,它可以从最基本的 CRUD 操作无缝扩展到复杂的实时应用,而不会让你陷入混乱的思维模型中。
不过在介绍这套“三层数据架构”之前,我们得先谈谈一个常见的起点:
你的第一反应可能是直接在组件里用 useEffect()
搭配 fetch()
来获取数据,然后继续开发下去。
但这种方式,很快就会失控。以下是原因:
export function TeamDashboard() {
const [user, setUser] = useState(null);
const [org, setOrg] = useState(null);
const [teams, setTeams] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [isCreating, setIsCreating] = useState(false);
const [lastUpdated, setLastUpdated] = useState(null);
// Waterfall ❌
useEffect(() => {
const fetchData = async () => {
try {
// User request
const userData = await fetch("/api/user").then((res) => res.json());
setUser(userData);
// Wait for user, then fetch org
const orgData = await fetch(`/api/org/${userData.orgId}`).then((res) =>
res.json()
);
setOrg(orgData);
// Wait for org, then fetch teams
const teamsData = await fetch(`/api/teams?orgId=${orgData.id}`).then(
(res) => res.json()
);
setTeams(teamsData);
setIsLoading(false);
} catch (err) {
setError(err.message);
setIsLoading(false);
}
};
fetchData();
}, []);
// Handle window focus to refetch
useEffect(() => {
const handleFocus = async () => {
if (!user?.id) return;
setIsLoading(true);
await refetchData();
};
window.addEventListener("focus", handleFocus);
return () => window.removeEventListener("focus", handleFocus);
}, [user?.id]);
// Polling for updates
useEffect(() => {
if (!user?.id || !org?.id) return;
const pollTeams = async () => {
try {
const teamsData = await fetch(`/api/teams?orgId=${org.id}`).then(
(res) => res.json()
);
setTeams(teamsData);
} catch (err) {
// Silent fail or show error?
console.error("Polling failed:", err);
}
};
const interval = setInterval(pollTeams, 30000);
return () => clearInterval(interval);
}, [user?.id, org?.id]);
const refetchData = async () => {
try {
const userData = await fetch("/api/user").then((res) => res.json());
const orgData = await fetch(`/api/org/${userData.orgId}`).then((res) =>
res.json()
);
const teamsData = await fetch(`/api/teams?orgId=${orgData.id}`).then(
(res) => res.json()
);
setUser(userData);
setOrg(orgData);
setTeams(teamsData);
setLastUpdated(new Date());
} catch (err) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
const createTeam = async (newTeam) => {
setIsCreating(true);
try {
const response = await fetch("/api/teams", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newTeam),
});
if (!response.ok) throw new Error("Failed to create team");
const createdTeam = await response.json();
// Optimistic update attempt
setTeams((prev) => [...prev, createdTeam]);
// Or full refetch because you're paranoid
await refetchData();
} catch (err) {
setError(err.message);
// Need to rollback optimistic update?
// But which teams were the original ones?
} finally {
setIsCreating(false);
}
};
// Component unmount cleanup
useEffect(() => {
return () => {
// Cancel any pending requests?
// How do we track them all?
};
}, []);
// The render logic is still complex
if (isLoading && !teams.length) {
return <LoadingSpinner />;
}
if (error) {
return <ErrorDisplay message={error} onRetry={refetchData} />;
}
return (
<div>
<h1>{org?.name}'s Dashboard</h1>
{isLoading && <div>Refreshing...</div>}
<TeamList teams={teams} onCreate={createTeam} isCreating={isCreating} />
{lastUpdated && (
<div>Last updated: {lastUpdated.toLocaleTimeString()}</div>
)}
</div>
);
}
这种“在组件中用 useEffect + fetch”的方式,存在大量问题:
- 瀑布式请求:请求按顺序依赖执行,效率低下(后面我们会详细讨论)
- 状态管理混乱:多个
useState
钩子相互独立,容易不同步 - 内存泄漏风险:事件监听器、定时器等需要手动清理,容易遗漏
- 无法取消请求:组件卸载时,无法终止正在进行中的请求
- 加载状态复杂:
isLoading
究竟是哪个请求在加载?多个并发请求怎么处理? - 错误处理难统一:错误冒泡到哪里?如何集中处理错误?
- 缓存数据过期问题:没有机制标记哪些数据已经过期
- 乐观更新灾难:需要手动写回滚逻辑,容易出错
- 依赖数组陷阱:一不小心漏了依赖,导致潜在 Bug 难以排查
- 测试极其困难:模拟这些副作用和状态逻辑是一场噩梦
当你的应用变得越来越复杂,这些问题会指数级地增长。每新增一个功能,就意味着更多的状态、更多的副作用、更多边界条件需要考虑。
当然,你也可以用 Redux 或 MobX 来集中管理状态,但这些库往往也会引入新的复杂度和大量样板代码。最终你会陷入一张难以理清的“action → reducer → selector”的关系网中。我自己也喜欢这两个库,但它们并不是解决这个问题的最佳答案。
你可能会想:“那我用 useReducer()
+ useContext()
管理状态不就好了?”
是的,这种组合确实可以整洁地组织状态,但它仍然没有解决数据获取本身的复杂性。加载状态、错误处理、缓存失效等问题依旧存在。
顺带一提,你可能还会想:“我干脆一次性把所有数据都请求回来,不就没这些问题了?”
接下来我们就来聊聊,为什么这也不可行。
export default function Dashboard() {
// Creates a waterfall - each request waits for the previous ❌
const { user } = useUser(); // Request 1
const { org } = useOrganization(user?.id); // Request 2 (waits)
const { teams } = useTeams(org?.id); // Request 3 (waits more)
// Total delay: 600-1200ms
return <DashboardView user={user} org={org} teams={teams} />;
}
Server Components(服务器组件)是一种更快、更高效的解决方案。它们允许你在服务器端获取数据,然后一次性将处理后的结果发送给客户端,从而:
- 减少前后端之间的网络请求次数
- 降低客户端的计算负担
- 提升页面加载速度和整体性能
通过在服务器上完成数据获取与渲染逻辑,Server Components 能帮助你构建更简洁、高性能的 React/Next.js 应用架构。
export default async function Dashboard() {
const user = await getUser();
// fetch org and teams in parallel using user data ✅
const [org, teams] = await Promise.all([
getOrganization(user.orgId),
getTeamsByOrgId(user.orgId),
]);
return <DashboardView user={user} org={org} teams={teams} />;
}
如果我告诉你,其实有一种更优雅的方式来组织数据获取逻辑,不仅能随着应用的增长而扩展,还能让你的组件保持简洁、专注 —— 你会不会感兴趣?
这正是 “三层数据架构(Three Layers of Data Architecture)” 的核心思想。这个模式将数据获取逻辑划分为三个清晰的层级,每一层都各司其职,互不干扰。
这样的设计让你的应用:
- 更容易理解
- 更方便测试
- 更便于维护和扩展
接下来我们就来深入了解这三层到底是什么。
三层数据架构
解决方案就是构建一个三层架构,实现关注点分离,让你的应用更容易理解、维护和扩展。
这种架构理念受到 React Query 的启发,它为管理服务端状态提供了一套强大且高效的解决方案。
你不一定非得使用 React Query,但我个人非常推荐它作为数据获取与缓存的首选库。
它帮你处理掉大量样板代码,让你可以专注于业务逻辑和界面开发。
💡 小提示:如果你选择使用 React Query,别忘了在开发环境中加上
<ReactQueryDevtools />
—— 这个调试工具会极大提升你的开发体验。
回到“三层架构”本身。其实它的结构非常简单:
- 服务器组件(Server Components) —— 负责初始数据获取
- React Query —— 处理客户端的缓存与数据更新
- 乐观更新(Optimistic Updates) —— 提供即时的 UI 反馈
React Query 支持两种方式来实现乐观更新(即在真正完成数据变更之前就提前更新界面):
- 使用
onMutate
钩子,直接操作缓存实现数据预更新 - 或者通过
useMutation
的返回值,根据变量手动更新 UI
这种模式不仅让用户感受到更快的响应,还能保持数据与界面的同步性。
下面是一个推荐的项目结构示例,用来更清晰地理解这三层架构的组织方式:
app/
├── page.tsx # Layer 1: Server Component entry
├── api/
│ └── teams/
│ └── route.ts # GET, POST teams
│ └── [teamId]/
│ └── route.ts # GET, PUT, DELETE specific team
├── TeamList.tsx # Client component consuming Layers 2 & 3
├── components/ # Fix: Add this folder
│ └── TeamCard.tsx
└── ui/
├── error-state.tsx # Layer 2: Error handling states
└── loading-state.tsx # Layer 2: Loading states
hooks/
├── teams/
│ ├── useTeamsData.ts # Layer 2: React Query hooks
│ └── useTeamMutations.ts # Layer 3: Mutations with optimism
queries/ # Layer 1: Server-side database queries
├── teams/
│ ├── getAllTeams .ts
│ ├── getTeamById.ts
│ ├── getTeamsByOrgId.ts
│ ├── deleteTeamById.ts
│ ├── createTeam.ts
│ ├── updateTeamById.ts
context/
└── OrganizationContext.tsx # Layer 2: Centralized data management
三层架构的数据如何流动?
这三个层按顺序工作但保持独立:
用户请求(User Request)
↓
【第一层:服务器组件(Server Component)】
- 调用 getAllTeams() 从数据库获取数据
- 返回已渲染的 HTML(含初始数据)
↓
【第二层:React Query(客户端状态管理)】
- 接收并“脱水”服务器返回的数据(hydrate)
- 管理客户端缓存
- 处理自动/手动重新请求(refetch)
↓
【第三层:用户交互(User Actions)】
- 执行乐观更新,立即反馈 UI
- 发起真实的变更请求(mutation)
- 自动或手动触发缓存失效(cache invalidation)
第一层:Server Components
服务器组件负责处理初始数据获取,让你的应用感觉即时可用。但它们不会动态更新——这时 React Query 就派上用场了(第二层)。
import { getAllTeams } from "@/queries/teams/getAllTeams";
import { TeamList } from "./TeamList";
import { OrganizationProvider } from "@/context/OrganizationContext";
export default async function Page() {
// Layer 1: Fetch initial data on server
const teams = await getAllTeams();
return (
<main>
<h1>Teams Dashboard</h1>
{/* Pass server data to React Query via context */}
<OrganizationProvider initialTeams={teams}>
<TeamList />
</OrganizationProvider>
</main>
);
}
getAllTeams
函数是一个简单的数据库查询,用于获取所有团队。它可以是一个简单的 SQL 查询,也可以是一个 ORM 调用,具体取决于您的设置。
如下代码所示:
import { db } from "@/lib/db"; // Database or ORM connection
import { Team } from "@/types/team";
import { NextResponse } from "next/server";
export async function getAllTeams(): Promise<Team[]> {
try {
const teams = await db.team.findMany();
return teams;
} catch (error) {
throw new Error("Failed to fetch teams");
}
}
第二层:React Query
第 2 层使用来自第 1 层的初始数据并管理客户端状态:
import { useQuery } from "@tanstack/react-query";
export function useTeamsData(initialData: Team[]) {
return useQuery({
queryKey: ["teams"],
queryFn: async () => {
// Client-side must use API routes, not direct queries
// I want to keep my server and client code separate
const response = await fetch("/api/teams");
if (!response.ok) throw new Error("Failed to fetch teams");
return response.json();
},
initialData, // Received from Server Component via context
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});
}
以下是客户端组件从第 2 层消费的方式。
"use client";
import { useOrganization } from "@/context/OrganizationContext";
import { LoadingState } from "@/ui/loading-state";
import { ErrorState } from "@/ui/error-state";
export function TeamList() {
// Data from Layer 2 context
const { teams, isLoadingTeams, error } = useOrganization();
if (error) {
return <ErrorState message="Failed to load teams" />;
}
if (isLoadingTeams) {
return <LoadingState />;
}
return (
<div>
{teams.map((team) => (
<TeamCard key={team.id} team={team} />
))}
</div>
);
}
真正的“魔法”发生在第三层,这一层让你在服务器还在处理请求时,就能立即更新 UI,带来极致流畅的用户体验 —— 这正是乐观更新(Optimistic Updates)的价值所在。
在这个层中,所有变更请求(mutations)都被集中管理,例如创建或删除团队。
我们通常会把这部分逻辑封装在一个独立的 Hook 中,比如 useTeamMutations
,它内部使用 React Query 的 useMutation
来处理对应的操作,从而让业务逻辑更清晰、职责更明确、代码更易维护。
// Layer 3: Mutations with optimism
import { useMutation, useQueryClient } from "@tanstack/react-query";
export function useTeamMutations() {
const queryClient = useQueryClient();
const createTeamMutation = useMutation({
mutationFn: async (newTeam: { name: string; members: string[] }) => {
const response = await fetch("/api/teams", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newTeam),
});
if (!response.ok) throw new Error("Failed to create team");
return response.json();
},
onMutate: async (newTeam) => {
await queryClient.cancelQueries({ queryKey: ["teams"] });
const currentTeams = queryClient.getQueryData(["teams"]);
queryClient.setQueryData(["teams"], (old) => [
...old,
{ ...newTeam, id: `temp-${Date.now()}` },
]);
return { currentTeams };
},
onError: (err, variables, context) => {
queryClient.setQueryData(["teams"], context.currentTeams);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["teams"] });
},
});
const deleteTeamMutation = useMutation({
mutationFn: async (teamId: string) => {
const response = await fetch(`/api/teams/${teamId}`, {
method: "DELETE",
});
if (!response.ok) throw new Error("Failed to delete team");
return response.json();
},
onMutate: async (teamId) => {
await queryClient.cancelQueries({ queryKey: ["teams"] });
const currentTeams = queryClient.getQueryData(["teams"]);
queryClient.setQueryData(["teams"], (old) =>
old.filter((team) => team.id !== teamId)
);
return { currentTeams };
},
onError: (err, teamId, context) => {
queryClient.setQueryData(["teams"], context.currentTeams);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["teams"] });
},
});
return {
createTeam: createTeamMutation.mutate,
deleteTeam: deleteTeamMutation.mutate,
isCreating: createTeamMutation.isLoading,
isDeleting: deleteTeamMutation.isLoading,
};
}
TeamCard 组件使用 useTeamMutations 钩子来处理团队的创建和删除。它还显示每个操作的加载状态。
"use client";
// TeamList.tsx - Using Layer 3 mutations
import { useTeamMutations } from "@/hooks/teams/useTeamMutations";
interface TeamCardProps {
team: {
id: string;
name: string;
members: string[];
};
}
export function TeamCard({ team }: TeamCardProps) {
const { deleteTeam, isDeleting } = useTeamMutations();
return (
<div className="p-4 border border-gray-200 rounded-lg mb-4">
<h3 className="text-lg font-semibold">{team.name}</h3>
<p className="text-gray-600">Members: {team.members.length}</p>
<button
onClick={() => deleteTeam(team.id)}
disabled={isDeleting}
className="mt-2 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
>
{isDeleting ? "Deleting..." : "Delete Team"}
</button>
</div>
);
}
将所有内容联系在一起:Context
上下文提供程序消除了 prop 钻取,并集中了数据访问。这对于多个组件需要相同数据的复杂应用尤其有用。
import { createContext, useContext } from "react";
import { useTeamsData } from "@/hooks/teams/useTeamsData";
interface OrganizationContextValue {
teams: Team[];
isLoadingTeams: boolean;
error: Error | null;
}
const OrganizationContext = createContext<OrganizationContextValue | null>(
null
);
export function OrganizationProvider({ children, initialTeams }) {
const { data: teams, isLoading, error } = useTeamsData(initialTeams);
return (
<OrganizationContext.Provider
value={{ teams, isLoadingTeams: isLoading, error }}
>
{children}
</OrganizationContext.Provider>
);
}
export function useOrganization() {
const context = useContext(OrganizationContext);
if (!context) {
throw new Error("useOrganization must be used within OrganizationProvider");
}
return context;
}
OrganizationProvider
组件包裹了 TeamList
,为其提供了第一层的初始数据,同时统一管理加载状态与错误处理。
在更复杂的应用中,你可以为不同的数据层增加更多的上下文(Context)提供器。
比如,你可能会有:
- 一个
UserContext
来管理用户信息 - 一个
AuthContext
来处理认证状态
通过这种方式,你的组件可以专注于渲染 UI,而数据获取与状态管理则被集中管理,逻辑更清晰、职责更分明。
需要注意的是:
对于简单应用而言,“三层数据架构”可能有点大材小用。
但对于中大型项目来说,它具有极高的可扩展性,能很好地应对不断增长的复杂度。
此外,它还让测试变得更加简单 —— 你可以通过模拟(mock)这些 Context Provider,来独立测试每个组件,无需依赖真实数据。
P.S.:这个架构不仅限于 React。你在 Vue.js、Svelte 或其他前端框架中也可以采用类似的思路。关键在于:关注点分离,让组件专注于“渲染”,而不是“获取数据”或“管理状态”。
总结
这篇文章介绍了一个适用于复杂 React/Next.js 应用的 三层数据架构,通过将数据获取流程拆分为 Server Components、React Query 和用户交互三层,解决了传统 useEffect + fetch 带来的各种性能与维护问题。该模式强调 关注点分离,提升了组件的复用性、可测试性和扩展能力。尽管对小项目可能偏重,但在中大型应用中具备良好的可扩展性和清晰的逻辑组织能力,是构建健壮前端架构的实用指南。
来源:juejin.cn/post/7503449107542016040