Remix数据获取模式 #
一、数据获取概述 #
Remix 提供了多种数据获取方式,适用于不同的场景。本章介绍常见的数据获取模式。
二、基础数据加载 #
2.1 页面数据 #
使用 loader 加载页面数据:
tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPost(params.id);
return json({ post });
}
export default function Post() {
const { post } = useLoaderData<typeof loader>();
return <article>{post.content}</article>;
}
2.2 嵌套路由数据 #
嵌套路由的 loader 并行执行:
tsx
// routes/blog.tsx
export async function loader() {
const categories = await getCategories();
return json({ categories });
}
// routes/blog._index.tsx
export async function loader() {
const posts = await getPosts();
return json({ posts });
}
三、分页实现 #
3.1 基础分页 #
tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData, Link, useSearchParams } from "@remix-run/react";
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get("page") || "1");
const perPage = 10;
const { posts, total } = await getPosts(page, perPage);
const totalPages = Math.ceil(total / perPage);
return json({ posts, page, totalPages });
}
export default function Posts() {
const { posts, page, totalPages } = useLoaderData<typeof loader>();
const [searchParams] = useSearchParams();
const buildUrl = (pageNum: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", String(pageNum));
return `?${params.toString()}`;
};
return (
<div>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<nav className="flex gap-2">
{page > 1 && (
<Link to={buildUrl(page - 1)}>上一页</Link>
)}
<span>第 {page} 页 / 共 {totalPages} 页</span>
{page < totalPages && (
<Link to={buildUrl(page + 1)}>下一页</Link>
)}
</nav>
</div>
);
}
3.2 带搜索的分页 #
tsx
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const query = url.searchParams.get("q") || "";
const page = parseInt(url.searchParams.get("page") || "1");
const { results, total } = await searchPosts(query, page);
return json({ results, query, page, total });
}
export default function Search() {
const { results, query, page, total } = useLoaderData<typeof loader>();
const [searchParams] = useSearchParams();
return (
<div>
<Form method="get">
<input
name="q"
defaultValue={query}
placeholder="搜索..."
/>
<button type="submit">搜索</button>
</Form>
<ul>
{results.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
{/* 分页控件 */}
</div>
);
}
四、搜索功能 #
4.1 实时搜索 #
使用 useFetcher 实现实时搜索:
tsx
import { useFetcher } from "@remix-run/react";
import { useEffect, useState } from "react";
export default function Search() {
const fetcher = useFetcher<typeof loader>();
const [query, setQuery] = useState("");
useEffect(() => {
if (query.length > 2) {
fetcher.load(`/api/search?q=${encodeURIComponent(query)}`);
}
}, [query]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索..."
/>
{fetcher.state === "loading" && <div>搜索中...</div>}
{fetcher.data?.results && (
<ul>
{fetcher.data.results.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
)}
</div>
);
}
4.2 防抖搜索 #
tsx
import { useFetcher, useSearchParams } from "@remix-run/react";
import { useEffect, useRef } from "react";
function useDebounce(value: string, delay: number) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
export default function Search() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
const fetcher = useFetcher<typeof loader>();
useEffect(() => {
if (debouncedQuery) {
fetcher.load(`/api/search?q=${encodeURIComponent(debouncedQuery)}`);
}
}, [debouncedQuery]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索..."
/>
{/* 结果显示 */}
</div>
);
}
五、无限滚动 #
5.1 基础实现 #
tsx
import { useEffect, useRef, useState } from "react";
import { useFetcher } from "@remix-run/react";
interface Post {
id: string;
title: string;
}
export default function InfiniteScroll() {
const [posts, setPosts] = useState<Post[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const loaderRef = useRef<HTMLDivElement>(null);
const fetcher = useFetcher<typeof loader>();
useEffect(() => {
if (fetcher.data) {
setPosts((prev) => [...prev, ...fetcher.data.posts]);
setHasMore(fetcher.data.hasMore);
}
}, [fetcher.data]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && fetcher.state === "idle") {
const nextPage = page + 1;
setPage(nextPage);
fetcher.load(`/api/posts?page=${nextPage}`);
}
},
{ threshold: 0.1 }
);
if (loaderRef.current) {
observer.observe(loaderRef.current);
}
return () => observer.disconnect();
}, [hasMore, page, fetcher]);
return (
<div>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<div ref={loaderRef}>
{fetcher.state === "loading" && <div>加载中...</div>}
{!hasMore && <div>没有更多了</div>}
</div>
</div>
);
}
5.2 使用Loader初始数据 #
tsx
import { useLoaderData, useFetcher } from "@remix-run/react";
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get("page") || "1");
const { posts, hasMore } = await getPosts(page);
return json({ posts, hasMore, page });
}
export default function Posts() {
const initialData = useLoaderData<typeof loader>();
const [posts, setPosts] = useState(initialData.posts);
const [page, setPage] = useState(initialData.page);
const [hasMore, setHasMore] = useState(initialData.hasMore);
const fetcher = useFetcher<typeof loader>();
// ... 无限滚动逻辑
}
六、数据刷新 #
6.1 手动刷新 #
tsx
import { useRevalidator } from "@remix-run/react";
export default function Page() {
const revalidator = useRevalidator();
const { data } = useLoaderData<typeof loader>();
return (
<div>
<button
onClick={() => revalidator.revalidate()}
disabled={revalidator.state === "loading"}
>
刷新数据
</button>
{/* 数据显示 */}
</div>
);
}
6.2 定时刷新 #
tsx
import { useRevalidator } from "@remix-run/react";
import { useEffect } from "react";
export default function RealTimeData() {
const revalidator = useRevalidator();
useEffect(() => {
const interval = setInterval(() => {
if (revalidator.state === "idle") {
revalidator.revalidate();
}
}, 30000);
return () => clearInterval(interval);
}, [revalidator]);
return <div>{/* 实时数据 */}</div>;
}
七、预加载 #
7.1 链接预加载 #
tsx
import { Link } from "@remix-run/react";
export default function PostList({ posts }) {
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link
to={`/posts/${post.id}`}
prefetch="intent"
>
{post.title}
</Link>
</li>
))}
</ul>
);
}
7.2 手动预加载 #
tsx
import { useFetcher } from "@remix-run/react";
export default function PostCard({ post }) {
const fetcher = useFetcher();
const handleMouseEnter = () => {
fetcher.load(`/posts/${post.id}`);
};
return (
<div onMouseEnter={handleMouseEnter}>
<Link to={`/posts/${post.id}`}>
{post.title}
</Link>
</div>
);
}
八、条件数据加载 #
8.1 用户相关数据 #
tsx
export async function loader({ request }: LoaderFunctionArgs) {
const user = await getUser(request);
if (!user) {
return json({ user: null, data: null });
}
const data = await getUserData(user.id);
return json({ user, data });
}
export default function Page() {
const { user, data } = useLoaderData<typeof loader>();
if (!user) {
return <div>请登录</div>;
}
return <div>{/* 用户数据 */}</div>;
}
8.2 权限相关数据 #
tsx
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireUser(request);
const permissions = await getPermissions(user.id);
const data = await getData(user.id);
return json({ user, permissions, data });
}
九、错误处理 #
9.1 加载失败处理 #
tsx
export default function Page() {
const { data } = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const handleRetry = () => {
fetcher.load(window.location.pathname);
};
if (fetcher.state === "idle" && fetcher.data === undefined && !data) {
return (
<div>
<p>加载失败</p>
<button onClick={handleRetry}>重试</button>
</div>
);
}
return <div>{/* 数据 */}</div>;
}
十、最佳实践 #
10.1 合理使用预加载 #
tsx
// 高优先级页面
<Link to="/dashboard" prefetch="render">仪表板</Link>
// 低优先级页面
<Link to="/settings" prefetch="intent">设置</Link>
10.2 避免过度请求 #
tsx
// 使用防抖
const debouncedQuery = useDebounce(query, 300);
// 使用缓存
const cachedData = useRouteLoaderData("routes/posts");
10.3 优雅的加载状态 #
tsx
export default function Page() {
const navigation = useNavigation();
const { data } = useLoaderData<typeof loader>();
const isLoading = navigation.state === "loading";
return (
<div className={isLoading ? "opacity-50" : ""}>
{isLoading && <LoadingSpinner />}
<Content data={data} />
</div>
);
}
十一、总结 #
本章我们学习了:
- 分页实现:基础分页和带搜索的分页
- 搜索功能:实时搜索和防抖搜索
- 无限滚动:使用 IntersectionObserver
- 数据刷新:手动刷新和定时刷新
- 预加载:链接预加载和手动预加载
核心要点:
- 使用
useFetcher进行客户端数据获取 - 使用
useRevalidator刷新数据 - 合理使用预加载提升用户体验
- 实现优雅的加载和错误状态
最后更新:2026-03-28