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>
  );
}

十一、总结 #

本章我们学习了:

  1. 分页实现:基础分页和带搜索的分页
  2. 搜索功能:实时搜索和防抖搜索
  3. 无限滚动:使用 IntersectionObserver
  4. 数据刷新:手动刷新和定时刷新
  5. 预加载:链接预加载和手动预加载

核心要点:

  • 使用 useFetcher 进行客户端数据获取
  • 使用 useRevalidator 刷新数据
  • 合理使用预加载提升用户体验
  • 实现优雅的加载和错误状态
最后更新:2026-03-28