注册
web

别再用 useEffect 写满组件了!试试这个三层数据架构 🤔🤔🤔


面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系:yunmz777



我们常常低估了数据获取的复杂性,直到项目已经陷入困境。很多项目一开始只是简单地在组件中随手使用 useEffect()fetch()


然而不知不觉中,错误处理、加载状态、缓存逻辑和重复请求越堆越多,代码变得混乱难以维护,调试也越来越痛苦。


以下是我在许多项目中常见的一些问题:



  1. 组件触发了重复的网络请求 —— 只是因为没有正确缓存数据
  2. 组件频繁重渲染 —— 状态管理混乱,每秒更新几十次
  3. 过多的骨架屏加载效果 —— 导致整个应用看起来总是“卡在加载中”
  4. 用户看到旧数据 —— 修改数据后缓存没有及时更新
  5. 并发请求出现竞态条件 —— 不同请求返回顺序无法预测,导致数据错乱
  6. 内存泄漏 —— 订阅和事件监听未正确清理
  7. 乐观更新失败却悄无声息 —— 页面展示和实际数据不一致
  8. 服务端渲染数据失效太快 —— 页面跳转后立即变成过期数据
  9. 无效的轮询逻辑 —— 要么频繁请求没有变化的数据,要么根本没必要轮询
  10. 组件与数据请求逻辑强耦合 —— 导致组件复用性极差
  11. 顺序依赖的多层请求链 —— 比如:获取用户 → 用户所属组织 → 组织下的团队 → 团队成员

以上问题在复杂应用中极为常见。如果不从架构层面进行规划和优化,很容易陷入混乱的技术债中,影响项目长期维护与扩展。


这些问题会互相叠加。一个糟糕的数据获取模式,往往会引发三个新的问题。等你意识到时,原本“简单”的仪表盘页面已经需要从头重构了。


这篇文章将向你展示一种更好的做法,至少是我在项目中偏好的架构方式。我们将构建一个三层数据获取架构,它可以从最基本的 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 /> —— 这个调试工具会极大提升你的开发体验。



20250513092135


回到“三层架构”本身。其实它的结构非常简单:



  1. 服务器组件(Server Components) —— 负责初始数据获取
  2. React Query —— 处理客户端的缓存与数据更新
  3. 乐观更新(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 带来的各种性能与维护问题。该模式强调 关注点分离,提升了组件的复用性、可测试性和扩展能力。尽管对小项目可能偏重,但在中大型应用中具备良好的可扩展性和清晰的逻辑组织能力,是构建健壮前端架构的实用指南。


作者:Moment
来源:juejin.cn/post/7503449107542016040

0 个评论

要回复文章请先登录注册