perf: cache and prefetch course pages

This commit is contained in:
afterhours247
2026-07-22 05:22:46 +08:00
parent 16fea94bb4
commit ea72934716
+145 -42
View File
@@ -1,14 +1,24 @@
'use client' 'use client'
import { useState, useEffect, useCallback } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
export interface CourseTag {
id: string
name: string
color?: string | null
_count?: { courseTags: number }
}
export interface Course { export interface Course {
id: string id: string
name: string name: string
displayName?: string | null
slug: string slug: string
thumbnail: string | null thumbnail: string | null
description: string | null description: string | null
path: string path: string
favorited?: boolean
tags?: CourseTag[]
createdAt: string createdAt: string
updatedAt: string updatedAt: string
_count: { modules: number; lessons: number } _count: { modules: number; lessons: number }
@@ -22,7 +32,8 @@ export interface Course {
} }
interface CoursesResponse { interface CoursesResponse {
courses: any[] courses: Course[]
tags: CourseTag[]
pagination: { pagination: {
page: number page: number
limit: number limit: number
@@ -42,9 +53,36 @@ interface UseCoursesOptions {
initialSortOrder?: string initialSortOrder?: string
} }
interface CacheEntry {
data: CoursesResponse
expiresAt: number
}
const CACHE_TTL_MS = 20_000
const MAX_CACHE_ENTRIES = 24
const responseCache = new Map<string, CacheEntry>()
function readCache(key: string): CoursesResponse | null {
const cached = responseCache.get(key)
if (!cached) return null
if (cached.expiresAt <= Date.now()) {
responseCache.delete(key)
return null
}
return cached.data
}
function writeCache(key: string, data: CoursesResponse) {
if (responseCache.size >= MAX_CACHE_ENTRIES) {
const oldestKey = responseCache.keys().next().value
if (oldestKey) responseCache.delete(oldestKey)
}
responseCache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS })
}
export function useCourses(options: UseCoursesOptions = {}) { export function useCourses(options: UseCoursesOptions = {}) {
const [courses, setCourses] = useState<any[]>([]) const [courses, setCourses] = useState<Course[]>([])
const [tags, setTags] = useState<any[]>([]) const [tags, setTags] = useState<CourseTag[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
@@ -60,19 +98,38 @@ export function useCourses(options: UseCoursesOptions = {}) {
const [sortBy, setSortBy] = useState(options.initialSortBy || 'updatedAt') const [sortBy, setSortBy] = useState(options.initialSortBy || 'updatedAt')
const [sortOrder, setSortOrder] = useState(options.initialSortOrder || 'desc') const [sortOrder, setSortOrder] = useState(options.initialSortOrder || 'desc')
const [debouncedSearch, setDebouncedSearch] = useState(options.initialSearch || '') const [debouncedSearch, setDebouncedSearch] = useState(options.initialSearch || '')
const abortRef = useRef<AbortController | null>(null)
const requestIdRef = useRef(0)
const hasDataRef = useRef(false)
// Debounce search
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = window.setTimeout(() => {
setDebouncedSearch(search) setDebouncedSearch(search)
setPagination(prev => ({ ...prev, page: 1 })) setPagination((previous) => ({ ...previous, page: 1 }))
}, 300) }, 250)
return () => clearTimeout(timer) return () => window.clearTimeout(timer)
}, [search]) }, [search])
const fetchCourses = useCallback(async () => { const applyResponse = useCallback((data: CoursesResponse) => {
setLoading(true) hasDataRef.current = true
setError(null) setCourses(data.courses)
setTags(data.tags || [])
setPagination((previous) => ({
...previous,
page: data.pagination.page,
limit: data.pagination.limit,
total: data.pagination.total,
totalPages: data.pagination.totalPages,
hasNext: data.pagination.hasNext,
hasPrev: data.pagination.hasPrev,
}))
}, [])
const fetchCourses = useCallback(async (force = false) => {
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
const requestId = ++requestIdRef.current
const params = new URLSearchParams({ const params = new URLSearchParams({
page: pagination.page.toString(), page: pagination.page.toString(),
@@ -82,72 +139,118 @@ export function useCourses(options: UseCoursesOptions = {}) {
sortBy, sortBy,
sortOrder, sortOrder,
}) })
const requestKey = params.toString()
const cached = force ? null : readCache(requestKey)
if (cached) {
applyResponse(cached)
setLoading(false)
setError(null)
return
}
if (!hasDataRef.current) setLoading(true)
setError(null)
try { try {
const response = await fetch(`/api/courses?${params.toString()}`) const response = await fetch(`/api/courses?${requestKey}`, {
signal: controller.signal,
headers: { Accept: 'application/json' },
})
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch courses') throw new Error('Failed to fetch courses')
} }
const data = await response.json()
setCourses(data.courses) const data = await response.json() as CoursesResponse
setTags(data.tags || []) if (requestId !== requestIdRef.current) return
setPagination(prev => ({
...prev, writeCache(requestKey, data)
total: data.pagination.total, applyResponse(data)
totalPages: data.pagination.totalPages,
hasNext: data.pagination.hasNext, if (data.pagination.hasNext) {
hasPrev: data.pagination.hasPrev, const nextParams = new URLSearchParams(params)
})) nextParams.set('page', String(data.pagination.page + 1))
} catch (err) { const nextKey = nextParams.toString()
setError(err instanceof Error ? err.message : 'Failed to fetch courses')
if (!readCache(nextKey)) {
window.setTimeout(() => {
void fetch(`/api/courses?${nextKey}`, {
headers: { Accept: 'application/json' },
})
.then((nextResponse) => nextResponse.ok ? nextResponse.json() : null)
.then((nextData: CoursesResponse | null) => {
if (nextData) writeCache(nextKey, nextData)
})
.catch(() => undefined)
}, 150)
}
}
} catch (caughtError) {
if (caughtError instanceof DOMException && caughtError.name === 'AbortError') return
if (requestId !== requestIdRef.current) return
setError(caughtError instanceof Error ? caughtError.message : 'Failed to fetch courses')
} finally { } finally {
setLoading(false) if (requestId === requestIdRef.current) setLoading(false)
} }
}, [pagination.page, pagination.limit, debouncedSearch, filter, sortBy, sortOrder]) }, [
applyResponse,
pagination.page,
pagination.limit,
debouncedSearch,
filter,
sortBy,
sortOrder,
])
useEffect(() => { useEffect(() => {
fetchCourses() void fetchCourses()
return () => abortRef.current?.abort()
}, [fetchCourses]) }, [fetchCourses])
const goToPage = (page: number) => { const goToPage = (page: number) => {
setPagination(prev => ({ ...prev, page })) setPagination((previous) => ({ ...previous, page: Math.max(1, page) }))
} }
const nextPage = () => { const nextPage = () => {
if (pagination.hasNext) { if (pagination.hasNext) {
setPagination(prev => ({ ...prev, page: prev.page + 1 })) setPagination((previous) => ({ ...previous, page: previous.page + 1 }))
} }
} }
const prevPage = () => { const prevPage = () => {
if (pagination.hasPrev) { if (pagination.hasPrev) {
setPagination(prev => ({ ...prev, page: prev.page - 1 })) setPagination((previous) => ({ ...previous, page: Math.max(1, previous.page - 1) }))
} }
} }
const changeLimit = (limit: number) => { const changeLimit = (limit: number) => {
setPagination(prev => ({ ...prev, limit, page: 1 })) setPagination((previous) => ({ ...previous, limit, page: 1 }))
} }
const handleSearch = (value: string) => { const handleSearch = (value: string) => setSearch(value)
setSearch(value)
}
const handleFilter = (value: string) => { const handleFilter = (value: string) => {
setFilter(value) setFilter(value)
setPagination(prev => ({ ...prev, page: 1 })) setPagination((previous) => ({ ...previous, page: 1 }))
} }
const handleSort = (field: string) => { const handleSort = (field: string) => {
setSortBy(field) setSortBy(field)
setPagination(prev => ({ ...prev, page: 1 })) setPagination((previous) => ({ ...previous, page: 1 }))
}
const handleSortOrder = (value: string) => {
setSortOrder(value === 'asc' ? 'asc' : 'desc')
setPagination((previous) => ({ ...previous, page: 1 }))
} }
const toggleSortOrder = () => { const toggleSortOrder = () => {
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc') setSortOrder((previous) => previous === 'asc' ? 'desc' : 'asc')
setPagination(prev => ({ ...prev, page: 1 })) setPagination((previous) => ({ ...previous, page: 1 }))
} }
const refetch = useCallback(() => fetchCourses(true), [fetchCourses])
return { return {
courses, courses,
tags, tags,
@@ -161,12 +264,12 @@ export function useCourses(options: UseCoursesOptions = {}) {
sortBy, sortBy,
sortOrder, sortOrder,
setSortBy: handleSort, setSortBy: handleSort,
setSortOrder: toggleSortOrder, setSortOrder: handleSortOrder,
toggleSortOrder, toggleSortOrder,
goToPage, goToPage,
nextPage, nextPage,
prevPage, prevPage,
changeLimit, changeLimit,
refetch: fetchCourses, refetch,
} }
} }