Optimize frontend performance and add route-level web vitals tracking

This commit is contained in:
2569718930@qq.com
2026-03-18 20:38:43 +08:00
parent f0fcb1e379
commit 69f24ddc08
21 changed files with 503 additions and 1386 deletions
+19 -14
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { startTransition, useEffect, useMemo, useState } from "react";
import clsx from "clsx";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
@@ -57,18 +57,19 @@ export function CitySidebar() {
Record<RiskGroupKey, boolean>
>(DEFAULT_EXPANDED_GROUPS);
const sortedCities = useMemo(() => [...store.cities].sort((a, b) => {
const aSelected = a.name === selectedCity;
const bSelected = b.name === selectedCity;
if (aSelected !== bSelected) return aSelected ? -1 : 1;
const aGroup = toRiskGroup(a.risk_level);
const bGroup = toRiskGroup(b.risk_level);
return (
(riskOrder[aGroup] ?? 3) -
(riskOrder[bGroup] ?? 3) ||
a.display_name.localeCompare(b.display_name)
);
}), [store.cities, selectedCity]);
const sortedCities = useMemo(
() =>
[...store.cities].sort((a, b) => {
const aGroup = toRiskGroup(a.risk_level);
const bGroup = toRiskGroup(b.risk_level);
return (
(riskOrder[aGroup] ?? 3) -
(riskOrder[bGroup] ?? 3) ||
a.display_name.localeCompare(b.display_name)
);
}),
[store.cities],
);
const groupedCities = useMemo(() => {
const groups: Record<RiskGroupKey, CityListItem[]> = {
@@ -170,7 +171,11 @@ export function CitySidebar() {
key={city.name}
type="button"
className={clsx("city-item", isActive && "active")}
onClick={() => void store.selectCity(city.name)}
onClick={() =>
startTransition(() => {
void store.selectCity(city.name);
})
}
>
<div className="city-item-main">
<span className={clsx("risk-dot", city.risk_level)} />
+53 -6
View File
@@ -1,8 +1,8 @@
"use client";
import { ChartConfiguration } from "chart.js/auto";
import type { ChartConfiguration } from "chart.js";
import clsx from "clsx";
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { ForecastTable } from "@/components/dashboard/PanelSections";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
@@ -17,7 +17,10 @@ import {
function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
const { locale, t } = useI18n();
const chartData = getTemperatureChartData(detail, locale);
const chartData = useMemo(
() => getTemperatureChartData(detail, locale),
[detail, locale],
);
const canvasRef = useChart(() => {
if (!chartData) {
@@ -129,6 +132,7 @@ export function DetailPanel() {
const detail = store.selectedDetail;
const isPro = store.proAccess.subscriptionActive;
const panelRef = useRef<HTMLElement | null>(null);
const [heavyContentReady, setHeavyContentReady] = useState(false);
const isOverlayOpen =
Boolean(store.futureModalDate) ||
store.historyState.isOpen ||
@@ -139,7 +143,10 @@ export function DetailPanel() {
Boolean(detail) &&
!store.loadingState.cityDetail &&
!isOverlayOpen;
const profileStats = detail ? getCityProfileStats(detail, locale) : [];
const profileStats = useMemo(
() => (detail ? getCityProfileStats(detail, locale) : []),
[detail, locale],
);
const scenery = getCityScenery(detail?.name);
const blurActiveElement = () => {
if (typeof document === "undefined") return;
@@ -170,6 +177,42 @@ export function DetailPanel() {
panel.removeAttribute("inert");
}, [isVisible]);
useEffect(() => {
if (!isVisible || !detail) {
setHeavyContentReady(false);
return;
}
let canceled = false;
let timeoutId: number | null = null;
let idleId: number | null = null;
const win = typeof window !== "undefined" ? (window as any) : null;
const markReady = () => {
if (!canceled) {
setHeavyContentReady(true);
}
};
if (win && typeof win.requestIdleCallback === "function") {
idleId = win.requestIdleCallback(markReady, { timeout: 180 });
} else if (typeof window !== "undefined") {
timeoutId = window.setTimeout(markReady, 80);
} else {
setHeavyContentReady(true);
}
return () => {
canceled = true;
if (win && idleId != null && typeof win.cancelIdleCallback === "function") {
win.cancelIdleCallback(idleId);
}
if (timeoutId != null && typeof window !== "undefined") {
window.clearTimeout(timeoutId);
}
};
}, [detail, isVisible]);
return (
<aside
ref={panelRef}
@@ -295,10 +338,14 @@ export function DetailPanel() {
<section className="detail-section rounded-2xl">
<h3>{t("detail.todayMiniTrend")}</h3>
<DetailMiniTemperatureChart detail={detail} />
{heavyContentReady ? (
<DetailMiniTemperatureChart detail={detail} />
) : (
<div className="detail-mini-meta">{t("detail.loading")}</div>
)}
</section>
<ForecastTable />
{heavyContentReady ? <ForecastTable /> : null}
</>
)}
</div>
@@ -12,9 +12,9 @@ import {
Wind,
} from "lucide-react";
import { ChartConfiguration } from "chart.js/auto";
import type { ChartConfiguration } from "chart.js";
import clsx from "clsx";
import { CSSProperties } from "react";
import { CSSProperties, useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
@@ -186,8 +186,10 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
const detail = store.selectedDetail;
const view = detail ? getFutureModalView(detail, dateStr, locale) : null;
const isToday = detail ? dateStr === detail.local_date : false;
const todayChartData =
detail && isToday ? getTemperatureChartData(detail, locale) : null;
const todayChartData = useMemo(
() => (detail && isToday ? getTemperatureChartData(detail, locale) : null),
[detail, isToday, locale],
);
const canvasRef = useChart(() => {
if (!detail || !view) {
@@ -1,6 +1,6 @@
"use client";
import { ChartConfiguration } from "chart.js/auto";
import type { ChartConfiguration } from "chart.js";
import { useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
@@ -1,5 +1,7 @@
"use client";
import "leaflet/dist/leaflet.css";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useLeafletMap } from "@/hooks/useLeafletMap";
@@ -1,7 +1,8 @@
"use client";
import { ChartConfiguration } from "chart.js/auto";
import type { ChartConfiguration } from "chart.js";
import clsx from "clsx";
import { startTransition, useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useCityData, useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
@@ -230,7 +231,10 @@ export function HeroSummary() {
export function TemperatureChart() {
const { data } = useCityData();
const { locale, t } = useI18n();
const chartData = data ? getTemperatureChartData(data, locale) : null;
const chartData = useMemo(
() => (data ? getTemperatureChartData(data, locale) : null),
[data, locale],
);
const canvasRef = useChart(() => {
if (!data || !chartData) {
@@ -403,7 +407,7 @@ export function ProbabilityDistribution({
const marketNoText = toPercent(marketNoPrice);
const isToday = !targetDate || targetDate === detail.local_date;
const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : [];
const sortedMarketTopBuckets = (() => {
const sortedMarketTopBuckets = useMemo(() => {
const sorted = [...marketTopBuckets].sort(
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
);
@@ -417,7 +421,7 @@ export function ProbabilityDistribution({
if (deduped.length >= 4) break;
}
return deduped;
})();
}, [marketTopBuckets]);
const useMarketTopBuckets =
marketScan?.available && sortedMarketTopBuckets.length >= 2;
const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability);
@@ -729,7 +733,9 @@ export function ForecastTable() {
isSelected && "selected",
)}
onClick={() => {
store.openFutureModal(day.date);
startTransition(() => {
store.openFutureModal(day.date);
});
}}
>
<div className="f-date">