refactor: 重构作品播放页性能、媒体同步与内存管理
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { X } from "lucide-react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Comment, User } from "../types.ts";
|
||||
import { CommentList } from "./CommentList";
|
||||
|
||||
@@ -12,7 +12,16 @@ interface CommentPanelProps {
|
||||
mounted: boolean;
|
||||
}
|
||||
|
||||
export function CommentPanel({
|
||||
interface CommentsApiResponse {
|
||||
comments?: Comment[];
|
||||
total?: number;
|
||||
hasMore?: boolean;
|
||||
mode?: string;
|
||||
seed?: string | number;
|
||||
snapshot?: string | number;
|
||||
}
|
||||
|
||||
export const CommentPanel = memo(function CommentPanel({
|
||||
open,
|
||||
onClose,
|
||||
author,
|
||||
@@ -24,264 +33,198 @@ export function CommentPanel({
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
// ranked 排序的稳定参数(由后端返回,前端透传保证会话内稳定)
|
||||
const [rankParams, setRankParams] = useState<null | {
|
||||
seed: string;
|
||||
snapshot: string;
|
||||
}>(null);
|
||||
// 两套滚动容器与哨兵,分别对应横屏与竖屏面板
|
||||
const scrollRefLandscape = useRef<HTMLDivElement>(null);
|
||||
const sentinelRefLandscape = useRef<HTMLDivElement>(null);
|
||||
const scrollRefPortrait = useRef<HTMLDivElement>(null);
|
||||
const sentinelRefPortrait = useRef<HTMLDivElement>(null);
|
||||
const [hasOpened, setHasOpened] = useState(open);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const loadingRef = useRef(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const commentsCountRef = useRef(0);
|
||||
const hasMoreRef = useRef(true);
|
||||
const rankParamsRef = useRef<{ seed: string; snapshot: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setHasOpened(true);
|
||||
}, [open]);
|
||||
|
||||
// 加载评论
|
||||
const loadComments = useCallback(
|
||||
async (reset = false) => {
|
||||
if (loadingRef.current || (!reset && !hasMore)) return;
|
||||
async (reset = false): Promise<void> => {
|
||||
if (loadingRef.current && !reset) return;
|
||||
if (!reset && !hasMoreRef.current) return;
|
||||
|
||||
abortControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const skip = reset ? 0 : comments.length;
|
||||
const skip = reset ? 0 : commentsCountRef.current;
|
||||
const query = new URLSearchParams({
|
||||
skip: String(skip),
|
||||
take: String(20),
|
||||
mode: "ranked",
|
||||
});
|
||||
if (rankParams) {
|
||||
query.set("seed", rankParams.seed);
|
||||
query.set("snapshot", rankParams.snapshot);
|
||||
const currentRank = reset ? null : rankParamsRef.current;
|
||||
if (currentRank) {
|
||||
query.set("seed", currentRank.seed);
|
||||
query.set("snapshot", currentRank.snapshot);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/comments/${awemeId}?${query.toString()}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as CommentsApiResponse;
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
// 统一做一次基于 cid 的去重,避免分页偶发重复
|
||||
if (reset) {
|
||||
setComments(() => {
|
||||
const seen = new Set<string>();
|
||||
return (data.comments as Comment[]).filter((c) => {
|
||||
if (seen.has(c.cid)) return false;
|
||||
seen.add(c.cid);
|
||||
return true;
|
||||
});
|
||||
const incoming = Array.isArray(data.comments) ? data.comments : [];
|
||||
|
||||
setComments((prev) => {
|
||||
const source = reset ? incoming : [...prev, ...incoming];
|
||||
const seen = new Set<string>();
|
||||
const deduped = source.filter((c) => {
|
||||
if (seen.has(c.cid)) return false;
|
||||
seen.add(c.cid);
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
setComments((prev) => {
|
||||
const merged = [...prev, ...(data.comments as Comment[])];
|
||||
const seen = new Set<string>();
|
||||
// 保留首次出现的项,既保证顺序也避免重复
|
||||
return merged.filter((c) => {
|
||||
if (seen.has(c.cid)) return false;
|
||||
seen.add(c.cid);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
}
|
||||
commentsCountRef.current = deduped.length;
|
||||
return deduped;
|
||||
});
|
||||
|
||||
setTotal(typeof data.total === "number" ? data.total : 0);
|
||||
const nextHasMore = Boolean(data.hasMore);
|
||||
hasMoreRef.current = nextHasMore;
|
||||
setHasMore(nextHasMore);
|
||||
|
||||
setTotal(data.total);
|
||||
setHasMore(data.hasMore);
|
||||
if (data.mode === "ranked" && data.seed && data.snapshot) {
|
||||
// 初始化或重置时更新稳定参数
|
||||
setRankParams((prev) => {
|
||||
if (reset)
|
||||
return {
|
||||
seed: String(data.seed),
|
||||
snapshot: String(data.snapshot),
|
||||
};
|
||||
return (
|
||||
prev ?? {
|
||||
seed: String(data.seed),
|
||||
snapshot: String(data.snapshot),
|
||||
}
|
||||
);
|
||||
});
|
||||
if (reset || !rankParamsRef.current) {
|
||||
rankParamsRef.current = {
|
||||
seed: String(data.seed),
|
||||
snapshot: String(data.snapshot),
|
||||
};
|
||||
}
|
||||
} else if (reset) {
|
||||
setRankParams(null);
|
||||
rankParamsRef.current = null;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
console.error("加载评论失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
if (abortControllerRef.current === controller) {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
[awemeId, comments.length, hasMore],
|
||||
[awemeId],
|
||||
);
|
||||
|
||||
// 面板打开时加载初始评论
|
||||
// 切换作品时清理旧评论状态与未完成请求
|
||||
useEffect(() => {
|
||||
if (open && comments.length === 0) {
|
||||
loadComments(true);
|
||||
abortControllerRef.current?.abort();
|
||||
loadingRef.current = false;
|
||||
commentsCountRef.current = 0;
|
||||
hasMoreRef.current = true;
|
||||
rankParamsRef.current = null;
|
||||
setComments([]);
|
||||
setTotal(0);
|
||||
setHasMore(true);
|
||||
setLoading(false);
|
||||
|
||||
if (open) {
|
||||
void loadComments(true);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Intersection Observer 监听底部触发加载(针对横屏与竖屏两个容器分别监听)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const observers: IntersectionObserver[] = [];
|
||||
|
||||
const setup = (
|
||||
rootEl: HTMLDivElement | null,
|
||||
targetEl: HTMLDivElement | null,
|
||||
) => {
|
||||
if (!rootEl || !targetEl) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry.isIntersecting && !loadingRef.current && hasMore) {
|
||||
loadComments();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: rootEl,
|
||||
rootMargin: "0px 0px 200px 0px", // 距底部 200px 触发
|
||||
threshold: 0,
|
||||
},
|
||||
);
|
||||
io.observe(targetEl);
|
||||
observers.push(io);
|
||||
};
|
||||
|
||||
setup(scrollRefLandscape.current, sentinelRefLandscape.current);
|
||||
setup(scrollRefPortrait.current, sentinelRefPortrait.current);
|
||||
|
||||
return () => {
|
||||
for (const io of observers) io.disconnect();
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, [awemeId, open, loadComments]);
|
||||
|
||||
// Intersection Observer 监听底部触发分页加载(单容器)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const rootEl = scrollRef.current;
|
||||
const targetEl = sentinelRef.current;
|
||||
if (!rootEl || !targetEl) return;
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (
|
||||
entry?.isIntersecting &&
|
||||
!loadingRef.current &&
|
||||
hasMoreRef.current
|
||||
) {
|
||||
void loadComments(false);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: rootEl,
|
||||
rootMargin: "0px 0px 200px 0px",
|
||||
threshold: 0,
|
||||
},
|
||||
);
|
||||
|
||||
io.observe(targetEl);
|
||||
return () => io.disconnect();
|
||||
}, [open, hasMore, loadComments]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style jsx global>{`
|
||||
/* 自定义滚动条样式 */
|
||||
.comment-scroll::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
<aside
|
||||
className={`
|
||||
z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white overflow-hidden
|
||||
fixed inset-x-0 bottom-0 w-full h-[min(80vh,88dvh)] border-t border-white/10
|
||||
landscape:relative landscape:inset-auto landscape:h-full landscape:translate-y-0 landscape:border-t-0
|
||||
${mounted ? "transition-transform landscape:transition-[width] duration-200 ease-out" : ""}
|
||||
${
|
||||
open
|
||||
? "translate-y-0 pointer-events-auto landscape:w-[min(420px,36vw)] landscape:border-l landscape:border-white/10"
|
||||
: "translate-y-full pointer-events-none landscape:w-0 landscape:border-l-0"
|
||||
}
|
||||
|
||||
.comment-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.comment-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.comment-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
.comment-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 横屏评论面板:并排分栏 */}
|
||||
<aside
|
||||
className={`
|
||||
hidden landscape:flex
|
||||
z-30 flex-col bg-[rgba(22,22,22,0.92)] text-white
|
||||
relative h-full overflow-hidden
|
||||
${mounted ? "transition-[width] duration-200 ease-out" : ""}
|
||||
${open ? "w-[min(420px,36vw)] border-l border-white/10" : "w-0"}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
|
||||
<button
|
||||
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors"
|
||||
onClick={onClose}
|
||||
aria-label="关闭评论"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<div className="text-white font-semibold">
|
||||
评论 {total > 0 ? `(${total})` : ""}
|
||||
</div>
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between landscape:flex-row-reverse px-3 py-3 border-b border-white/10 shrink-0">
|
||||
<div className="text-white font-semibold">
|
||||
评论 {total > 0 ? `(${total})` : ""}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRefLandscape}
|
||||
className="p-3 overflow-auto comment-scroll"
|
||||
<button
|
||||
type="button"
|
||||
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors cursor-pointer"
|
||||
onClick={onClose}
|
||||
aria-label="关闭评论"
|
||||
>
|
||||
<CommentList
|
||||
author={author}
|
||||
createdAt={createdAt}
|
||||
comments={comments}
|
||||
/>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 底部加载触发区 */}
|
||||
{hasMore && <div ref={sentinelRefLandscape} className="h-1 w-full" />}
|
||||
<div ref={scrollRef} className="p-3 overflow-auto comment-scroll flex-1">
|
||||
{(open || hasOpened) && (
|
||||
<>
|
||||
<CommentList
|
||||
author={author}
|
||||
createdAt={createdAt}
|
||||
comments={comments}
|
||||
/>
|
||||
|
||||
{loading && (
|
||||
<div className="py-4 text-center text-white/60 text-sm">
|
||||
加载中...
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && comments.length > 0 && (
|
||||
<div className="py-4 text-center text-white/40 text-sm">
|
||||
没有更多评论了
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
{hasMore && <div ref={sentinelRef} className="h-1 w-full" />}
|
||||
|
||||
{/* 竖屏评论面板:bottom sheet */}
|
||||
<aside
|
||||
className={`
|
||||
landscape:hidden
|
||||
z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white
|
||||
fixed inset-x-0 bottom-0 w-full h-[min(80vh,88dvh)]
|
||||
${mounted ? "transition-transform duration-200 ease-out" : ""}
|
||||
border-t border-white/10
|
||||
${open ? "translate-y-0" : "translate-y-full"}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
|
||||
<div className="text-white font-semibold">
|
||||
评论 {total > 0 ? `(${total})` : ""}
|
||||
</div>
|
||||
<button
|
||||
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors"
|
||||
onClick={onClose}
|
||||
aria-label="关闭评论"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRefPortrait}
|
||||
className="p-3 overflow-auto comment-scroll"
|
||||
>
|
||||
<CommentList
|
||||
author={author}
|
||||
createdAt={createdAt}
|
||||
comments={comments}
|
||||
/>
|
||||
|
||||
{/* 底部加载触发区 */}
|
||||
{hasMore && <div ref={sentinelRefPortrait} className="h-1 w-full" />}
|
||||
|
||||
{loading && (
|
||||
<div className="py-4 text-center text-white/60 text-sm">
|
||||
加载中...
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && comments.length > 0 && (
|
||||
<div className="py-4 text-center text-white/40 text-sm">
|
||||
没有更多评论了
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
{loading && (
|
||||
<div className="py-4 text-center text-white/60 text-sm">
|
||||
加载中...
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && comments.length > 0 && (
|
||||
<div className="py-4 text-center text-white/40 text-sm">
|
||||
没有更多评论了
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user