React 集成
PocketBase JS SDK 可以轻松集成到 React 项目中。本文介绍 React Hooks、Context 封装以及常见使用场景。
npm install pocketbase# 或yarn add pocketbase# 或pnpm add pocketbase创建 PocketBase 实例
Section titled “创建 PocketBase 实例”创建 src/lib/pocketbase.ts:
import PocketBase from "pocketbase";
const pb = new PocketBase( import.meta.env.VITE_POCKETBASE_URL || "http://127.0.0.1:8090",);
export default pb;import PocketBase from "pocketbase";
// 扩展 PocketBase 类型declare module "pocketbase" { interface CollectionRecords { // 用户集合 users: { id: string; collectionId: string; collectionName: string; username?: string; email: string; emailVisibility: boolean; verified: boolean; name?: string; role?: "user" | "admin"; avatar?: string; created: string; updated: string; };
// 文章集合 posts: { id: string; collectionId: string; collectionName: string; title: string; slug: string; content: string; excerpt?: string; cover?: string; status: "draft" | "published" | "archived"; author: string; category?: string; tags?: string[]; featured: boolean; views: number; publishedAt?: string; created: string; updated: string; };
// 评论集合 comments: { id: string; content: string; post: string; author: string; parent?: string; status: "pending" | "approved" | "rejected"; created: string; updated: string; }; }}Context 封装
Section titled “Context 封装”AuthContext
Section titled “AuthContext”import { createContext, useContext, useEffect, useState, ReactNode } from 'react';import pb from '@/lib/pocketbase';
interface AuthContextType { user: any; token: string; isAuthenticated: boolean; login: (email: string, password: string) => Promise<void>; register: (email: string, password: string, passwordConfirm: string) => Promise<void>; logout: () => void; refresh: () => Promise<void>; loading: boolean;}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(pb.authStore.model); const [token, setToken] = useState(pb.authStore.token); const [loading, setLoading] = useState(false);
useEffect(() => { // 监听认证状态变化 const unsubscribe = pb.authStore.onChange((token, model) => { setToken(token); setUser(model); });
return unsubscribe; }, []);
const login = async (email: string, password: string) => { setLoading(true); try { const authData = await pb.collection('users').authWithPassword(email, password); return authData; } finally { setLoading(false); } };
const register = async (email: string, password: string, passwordConfirm: string) => { setLoading(true); try { const record = await pb.collection('users').create({ email, password, passwordConfirm }); return record; } finally { setLoading(false); } };
const logout = () => { pb.authStore.clear(); setUser(null); setToken(''); };
const refresh = async () => { try { await pb.collection('users').authRefresh(); } catch (err) { logout(); throw err; } };
return ( <AuthContext.Provider value={{ user, token, isAuthenticated: !!token, login, register, logout, refresh, loading }} > {children} </AuthContext.Provider> );}
export function useAuth() { const context = useContext(AuthContext); if (!context) { throw new Error('useAuth must be used within AuthProvider'); } return context;}自定义 Hooks
Section titled “自定义 Hooks”useCollection Hook
Section titled “useCollection Hook”import { useState, useEffect, useCallback } from "react";import pb from "@/lib/pocketbase";
interface UseCollectionOptions { expand?: string; autoFetch?: boolean;}
export function useCollection<T>( collectionName: string, options: UseCollectionOptions = {},) { const { expand = "", autoFetch = true } = options; const [items, setItems] = useState<any[]>([]); const [loading, setLoading] = useState(false); const [error, setError] = useState<Error | null>(null); const [totalItems, setTotalItems] = useState(0);
const getList = useCallback( async (page = 1, perPage = 20, filter = "", sort = "") => { setLoading(true); setError(null);
try { const result = await pb .collection(collectionName) .getList(page, perPage, { filter, sort, expand, }); setItems(result.items); setTotalItems(result.totalItems); return result; } catch (err) { setError(err as Error); throw err; } finally { setLoading(false); } }, [collectionName, expand], );
const getOne = useCallback( async (id: string) => { setLoading(true); setError(null);
try { const record = await pb .collection(collectionName) .getOne(id, { expand }); return record; } catch (err) { setError(err as Error); throw err; } finally { setLoading(false); } }, [collectionName, expand], );
const create = useCallback( async (data: any) => { setLoading(true); setError(null);
try { const record = await pb.collection(collectionName).create(data); setItems((prev) => [record, ...prev]); return record; } catch (err) { setError(err as Error); throw err; } finally { setLoading(false); } }, [collectionName], );
const update = useCallback( async (id: string, data: any) => { setLoading(true); setError(null);
try { const record = await pb.collection(collectionName).update(id, data); setItems((prev) => prev.map((item) => (item.id === id ? record : item)), ); return record; } catch (err) { setError(err as Error); throw err; } finally { setLoading(false); } }, [collectionName], );
const remove = useCallback( async (id: string) => { setLoading(true); setError(null);
try { await pb.collection(collectionName).delete(id); setItems((prev) => prev.filter((item) => item.id !== id)); } catch (err) { setError(err as Error); throw err; } finally { setLoading(false); } }, [collectionName], );
useEffect(() => { if (autoFetch) { getList(); } }, [autoFetch, getList]);
return { items, loading, error, totalItems, getList, getOne, create, update, remove, };}useRealtime Hook
Section titled “useRealtime Hook”import { useEffect, useRef } from "react";import pb from "@/lib/pocketbase";
export function useRealtime( collection: string, callback: (event: any) => void, filter = "*",) { const subscriptionRef = useRef<any>(null);
useEffect(() => { let mounted = true;
const subscribe = async () => { try { const sub = await pb.collection(collection).subscribe(filter, (e) => { if (mounted) { callback(e); } }); if (mounted) { subscriptionRef.current = sub; } } catch (err) { console.error("Subscription failed:", err); } };
subscribe();
return () => { mounted = false; subscriptionRef.current?.unsubscribe(); }; }, [collection, filter, callback]);
return subscriptionRef.current;}usePagination Hook
Section titled “usePagination Hook”import { useState, useCallback } from "react";
export function usePagination(initialPage = 1, initialPerPage = 20) { const [page, setPage] = useState(initialPage); const [perPage, setPerPage] = useState(initialPerPage);
const nextPage = useCallback(() => { setPage((prev) => prev + 1); }, []);
const prevPage = useCallback(() => { setPage((prev) => Math.max(1, prev - 1)); }, []);
const goToPage = useCallback((pageNumber: number) => { setPage(Math.max(1, pageNumber)); }, []);
const reset = useCallback(() => { setPage(initialPage); }, [initialPage]);
return { page, perPage, setPage, setPerPage, nextPage, prevPage, goToPage, reset, };}import { useState } from "react";import { useAuth } from "@/contexts/AuthContext";import { useNavigate } from "react-router-dom";
export function LoginForm() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const { login, loading } = useAuth(); const navigate = useNavigate();
async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError("");
try { await login(email, password); navigate("/dashboard"); } catch (err: any) { setError(err.message || "Login failed"); } }
return ( <form onSubmit={handleSubmit} className="login-form"> <div> <label htmlFor="email">Email</label> <input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required /> </div>
<div> <label htmlFor="password">Password</label> <input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required /> </div>
{error && <div className="error">{error}</div>}
<button type="submit" disabled={loading}> {loading ? "Logging in..." : "Login"} </button> </form> );}文章列表组件
Section titled “文章列表组件”import { useEffect } from "react";import { useCollection } from "@/hooks/useCollection";import { usePagination } from "@/hooks/usePagination";import { useRealtime } from "@/hooks/useRealtime";
export function PostList() { const { page, perPage, nextPage, prevPage } = usePagination(1, 10); const { items, loading, error, totalItems, getList } = useCollection( "posts", { expand: "author,category", autoFetch: false, }, );
// 实时订阅 useRealtime("posts", (e) => { console.log("Post changed:", e.action, e.record); });
useEffect(() => { getList(page, perPage, "status='published'", "-created"); }, [page, perPage, getList]);
if (loading && items.length === 0) { return <div>Loading...</div>; }
if (error) { return <div>Error: {error.message}</div>; }
return ( <div> <h1>Posts ({totalItems})</h1>
<div className="posts"> {items.map((post) => ( <article key={post.id} className="post"> <h2>{post.title}</h2> {post.expand?.author && ( <p className="author">By {post.expand.author.name}</p> )} <p>{post.excerpt}</p> </article> ))} </div>
<div className="pagination"> <button onClick={prevPage} disabled={page === 1}> Previous </button> <span>Page {page}</span> <button onClick={nextPage} disabled={items.length < perPage}> Next </button> </div> </div> );}文件上传组件
Section titled “文件上传组件”import { useState, useRef } from "react";import pb from "@/lib/pocketbase";
export function FileUpload() { const [uploading, setUploading] = useState(false); const [progress, setProgress] = useState(0); const [preview, setPreview] = useState<string | null>(null); const fileInput = useRef<HTMLInputElement>(null);
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) { const file = e.target.files?.[0]; if (file) { setPreview(URL.createObjectURL(file)); } }
async function handleUpload() { const file = fileInput.current?.files?.[0]; if (!file) return;
setUploading(true); setProgress(0);
return new Promise((resolve, reject) => { const formData = new FormData(); formData.append("file", file); formData.append("title", "Uploaded file");
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => { if (e.lengthComputable) { setProgress(Math.round((e.loaded / e.total) * 100)); } });
xhr.addEventListener("load", () => { if (xhr.status === 200) { const response = JSON.parse(xhr.responseText); resolve(response); } else { reject(new Error(xhr.statusText)); } setUploading(false); });
xhr.addEventListener("error", () => { reject(new Error("Upload failed")); setUploading(false); });
xhr.open("POST", `${pb.baseUrl}/api/collections/uploads/records`); xhr.setRequestHeader("Authorization", `Bearer ${pb.authStore.token}`); xhr.send(formData); }); }
return ( <div className="file-upload"> <input ref={fileInput} type="file" onChange={handleFileSelect} disabled={uploading} />
{preview && <img src={preview} alt="Preview" style={{ maxWidth: 200 }} />}
<button onClick={handleUpload} disabled={uploading || !preview}> {uploading ? `Uploading ${progress}%` : "Upload"} </button> </div> );}import { Navigate } from "react-router-dom";import { useAuth } from "@/contexts/AuthContext";
interface ProtectedRouteProps { children: React.ReactNode; requireAdmin?: boolean;}
export function ProtectedRoute({ children, requireAdmin = false,}: ProtectedRouteProps) { const { isAuthenticated, user } = useAuth();
if (!isAuthenticated) { return <Navigate to="/login" replace />; }
if (requireAdmin && user?.role !== "admin") { return <Navigate to="/" replace />; }
return <>{children}</>;}import { BrowserRouter, Routes, Route } from "react-router-dom";import { AuthProvider } from "./contexts/AuthContext";import { ProtectedRoute } from "./components/ProtectedRoute";import { LoginForm } from "./components/LoginForm";import { Dashboard } from "./components/Dashboard";
function App() { return ( <BrowserRouter> <AuthProvider> <Routes> <Route path="/login" element={<LoginForm />} /> <Route path="/dashboard" element={ <ProtectedRoute> <Dashboard /> </ProtectedRoute> } /> </Routes> </AuthProvider> </BrowserRouter> );}
export default App;- 使用 TypeScript:充分利用类型安全,定义好集合类型
- 自定义 Hooks:将通用逻辑封装成 hooks 复用
- 错误边界:使用 Error Boundary 捕获组件树中的错误
- 请求取消:组件卸载时取消未完成的请求
- 乐观更新:先更新 UI,失败时回滚
- 防抖节流:搜索、滚动等场景使用防抖节流
// 请求取消示例useEffect(() => { const abortController = new AbortController();
const fetchData = async () => { try { const result = await pb.collection("posts").getList(1, 20, { // 传递 signal 以支持取消 }); } catch (err) { if (err.name !== "AbortError") { setError(err); } } };
fetchData();
return () => { abortController.abort(); };}, []);