perf: optimize frontend with code splitting, memoization, and tree shaking

- Implemented lazy loading for weather hub tabs
- Added React.memo to weather components for performance
- Optimized data processing with useMemo in dashboard
- Enabled tree-shaking in Vite build
- Improved bundle size by removing unused imports
This commit is contained in:
2026-04-23 20:50:23 +08:00
parent 3fa5720b5a
commit 6afd515b62
8 changed files with 185 additions and 93 deletions
+4 -4
View File
@@ -36,7 +36,7 @@
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react-swc": "^3.9.0",
"dotenv": "^16.5.0",
"typescript": "^5.8.3",
"typescript": "^5.9.3",
"vite": "^6.3.5"
}
},
@@ -5095,9 +5095,9 @@
}
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
+1 -1
View File
@@ -39,7 +39,7 @@
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react-swc": "^3.9.0",
"dotenv": "^16.5.0",
"typescript": "^5.8.3",
"typescript": "^5.9.3",
"vite": "^6.3.5"
}
}
@@ -1,4 +1,4 @@
import {
import React, {
Badge,
Box,
Button,
@@ -14,7 +14,7 @@ import {
VStack,
} from "@chakra-ui/react"
import { useQuery } from "@tanstack/react-query"
import { useState } from "react"
import { useState, useMemo } from "react"
import useAuth from "@/hooks/useAuth"
@@ -160,11 +160,31 @@ function FuryDashboard() {
retry: 1,
})
// Memoize the data processing to avoid recomputing on every render
const processedData = useMemo(() => {
if (!data) return null;
const latestSampleTimes = Object.values(data.metrics)
.map((metric) => metric.latest?.updateTime)
.filter((value): value is string => Boolean(value))
.sort((left, right) =>
new Date(right).getTime() - new Date(left).getTime(),
)
const newestSampleTime = latestSampleTimes[0] ?? data.generatedAt
const welcomeName = currentUser?.full_name || currentUser?.email || "there"
return {
...data,
newestSampleTime,
welcomeName
}
}, [data, currentUser])
if (isLoading) {
return <DashboardLoading />
}
if (error || !data) {
if (error || !processedData) {
return (
<Container maxW="full" py={8}>
<Card.Root>
@@ -182,14 +202,7 @@ function FuryDashboard() {
)
}
const latestSampleTimes = Object.values(data.metrics)
.map((metric) => metric.latest?.updateTime)
.filter((value): value is string => Boolean(value))
.sort((left, right) =>
new Date(right).getTime() - new Date(left).getTime(),
)
const newestSampleTime = latestSampleTimes[0] ?? data.generatedAt
const welcomeName = currentUser?.full_name || currentUser?.email || "there"
const { generatedAt, metrics, recentSamples, newestSampleTime, welcomeName } = processedData
return (
<Container maxW="full" py={8}>
@@ -248,12 +261,12 @@ function FuryDashboard() {
))}
</HStack>
<Text maxW="4xl">{buildComfortSummary(data)}</Text>
<Text maxW="4xl">{buildComfortSummary(processedData)}</Text>
</Card.Body>
</Card.Root>
<SimpleGrid columns={{ base: 1, lg: 3 }} gap={4}>
{Object.values(data.metrics).map((metric) => (
{Object.values(metrics).map((metric) => (
<Card.Root key={metric.key}>
<Card.Body gap={5}>
<Flex justify="space-between" gap={4}>
@@ -341,7 +354,7 @@ function FuryDashboard() {
</Text>
</Box>
<Text color="gray.500" fontSize="sm">
Last fetch {formatTimestamp(data.generatedAt)}
Last fetch {formatTimestamp(generatedAt)}
</Text>
</Flex>
@@ -355,7 +368,7 @@ function FuryDashboard() {
</Table.Row>
</Table.Header>
<Table.Body>
{data.recentSamples.map((sample) => (
{recentSamples.map((sample) => (
<Table.Row key={sample.updateTime}>
<Table.Cell>{formatTimestamp(sample.updateTime)}</Table.Cell>
<Table.Cell>{sample.temperature ?? "--"}</Table.Cell>
@@ -372,4 +385,4 @@ function FuryDashboard() {
)
}
export default FuryDashboard
export default React.memo(FuryDashboard)
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Spinner, Text, Box, VStack, SimpleGrid, Heading } from '@chakra-ui/react';
import { Card } from '@chakra-ui/react';
@@ -18,18 +18,30 @@ const AirTemperature: React.FC = () => {
refetchInterval: 1000 * 60 * 30,
});
// Memoize the data processing to avoid recomputing on every render
const processedData = useMemo(() => {
if (!data?.data) return { stations: [], readings: [], latestReading: null, avgTemp: 0 };
const { stations = [], readings = [] } = data.data;
const latestReading = readings[0];
const validReadings = latestReading?.data?.filter(r => r.value !== null) || [];
const avgTemp = validReadings.length > 0
? validReadings.reduce((sum, r) => sum + r.value, 0) / validReadings.length
: 0;
return { stations, readings, latestReading, avgTemp };
}, [data]);
if (isLoading) return <Spinner />;
if (error || !data?.data) return <Text color="red.500">Error loading temperature data</Text>;
if (error || !processedData.stations.length || !processedData.readings.length) return <Text color="red.500">Error loading temperature data</Text>;
const { stations = [], readings = [] } = data.data;
if (stations.length === 0 || readings.length === 0) return <Text>No temperature data available</Text>;
const latestReading = readings[0];
const { stations, latestReading } = processedData;
if (!latestReading || !latestReading.data) return <Text>No temperature data available</Text>;
const stationMap = new Map(stations.map(s => [s.id, s.name]));
const validReadings = latestReading.data.filter(r => r.value !== null);
const avgTemp = validReadings.length > 0
const avgTempValue = validReadings.length > 0
? validReadings.reduce((sum, r) => sum + r.value, 0) / validReadings.length
: 0;
@@ -39,7 +51,7 @@ const AirTemperature: React.FC = () => {
<Card.Root>
<Card.Body>
<Heading size="md" mb={2}>Average Temperature</Heading>
<Text fontSize="3xl" fontWeight="bold">{avgTemp.toFixed(1)}°C</Text>
<Text fontSize="3xl" fontWeight="bold">{avgTempValue.toFixed(1)}°C</Text>
<Text fontSize="sm" color="gray.500">Based on {validReadings.length} station readings</Text>
<Text fontSize="xs" color="gray.400" mt={1}>Last updated: {new Date(latestReading.timestamp).toLocaleString()}</Text>
</Card.Body>
@@ -61,4 +73,4 @@ const AirTemperature: React.FC = () => {
);
};
export default AirTemperature;
export default React.memo(AirTemperature);
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Box, Flex, Text, HStack, Badge, SimpleGrid, Spinner } from '@chakra-ui/react';
import { WeatherService } from '@/client/sdk.gen';
@@ -21,14 +21,21 @@ const TwoHourForecast: React.FC = () => {
refetchInterval: 1000 * 60 * 30,
});
// Memoize the data processing to avoid recomputing on every render
const processedData = useMemo(() => {
if (!data?.data) return { items: [], forecasts: [], validPeriod: null };
const items = data.data.items || [];
const forecasts = items[0]?.forecasts || [];
const validPeriod = items[0]?.valid_period || null;
return { items, forecasts, validPeriod };
}, [data]);
if (isLoading) return <Spinner />;
if (error || !data?.data) return <Text color="red.500">Error loading forecast data</Text>;
if (error || !processedData.items.length) return <Text color="red.500">Error loading forecast data</Text>;
const items = data.data.items || [];
if (!items.length) return <Text>No forecast data available</Text>;
const forecasts = items[0].forecasts || [];
const validPeriod = items[0].valid_period;
const { forecasts, validPeriod } = processedData;
return (
<Box>
@@ -65,11 +72,11 @@ const TwoHourForecast: React.FC = () => {
)}
{viewMode === 'map' && (
<Box height="500px" width="100%" borderRadius="md" overflow="hidden" my={4}>
<WeatherMap forecastData={data} />
<WeatherMap />
</Box>
)}
</Box>
);
};
export default TwoHourForecast;
export default React.memo(TwoHourForecast);
+69 -41
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Spinner, Text, Box } from '@chakra-ui/react';
import { MapContainer, TileLayer, Marker, Popup, ScaleControl, ZoomControl } from 'react-leaflet';
@@ -34,12 +34,6 @@ interface ForecastWithLocation {
location: { latitude: number; longitude: number };
}
interface WeatherMapProps {
forecastData?: ApiNestedResponse<TwoHourForecastPayload>;
tempData?: ApiNestedResponse<AirTemperaturePayload>;
windData?: ApiNestedResponse<WindDirectionPayload>;
}
const center: [number, number] = [1.3521, 103.8198];
const escapeHtml = (value: string) =>
@@ -97,7 +91,7 @@ const makeForecastMarkerHtml = ({
</div>
`;
const WeatherMap: React.FC<WeatherMapProps> = ({ forecastData, tempData, windData }) => {
const WeatherMap: React.FC<{ forecastData?: ApiNestedResponse<TwoHourForecastPayload>; tempData?: ApiNestedResponse<AirTemperaturePayload>; windData?: ApiNestedResponse<WindDirectionPayload>; }> = ({ forecastData, tempData, windData }) => {
const { data: fetchedTempData, isLoading: tempLoading, error: tempError } = useQuery<ApiNestedResponse<AirTemperaturePayload>>({
queryKey: ['weather', 'air-temperature'],
queryFn: () => WeatherService.getAirTemperature() as Promise<ApiNestedResponse<AirTemperaturePayload>>,
@@ -133,42 +127,76 @@ const WeatherMap: React.FC<WeatherMapProps> = ({ forecastData, tempData, windDat
return <Text color="red.500">Error loading map data</Text>;
}
const tempStations = actualTempData.data?.stations || [];
const tempReadings = actualTempData.data?.readings || [];
const windReadings = actualWindData.data?.readings || [];
const items = actualForecastData.data?.items || [];
const forecasts = items[0]?.forecasts || [];
const areaMetadata = actualForecastData.data?.area_metadata || [];
// Memoize the data processing to avoid recomputing on every render
const processedData = useMemo(() => {
const tempStations = actualTempData.data?.stations || [];
const tempReadings = actualTempData.data?.readings || [];
const windReadings = actualWindData.data?.readings || [];
const items = actualForecastData.data?.items || [];
const forecasts = items[0]?.forecasts || [];
const areaMetadata = actualForecastData.data?.area_metadata || [];
if (tempStations.length === 0 || tempReadings.length === 0) {
if (tempStations.length === 0 || tempReadings.length === 0) {
return null;
}
const latestTempReading = tempReadings[0];
const latestWindReading = windReadings[0];
if (!latestTempReading || !latestTempReading.data) {
return null;
}
const latestReadingTime = formatTime(latestTempReading.timestamp);
const tempStationMap = new Map(tempStations.map((st: AirTempStation) => [st.id, st]));
const windDataMap = new Map(latestWindReading?.data?.map((rd: WindDataPoint) => [rd.stationId, rd.value]) || []);
const getCardinalDirection = (deg: number | null | undefined) => {
if (deg === null || deg === undefined) return 'N/A';
const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
const idx = Math.round((deg % 360) / 22.5) % 16;
return dirs[idx];
};
const forecastsWithLocation = forecasts
.map(f => ({
area: f.area,
forecast: f.forecast,
location: areaMetadata.find(a => a.name === f.area)?.label_location,
}))
.filter(f => f.location) as ForecastWithLocation[];
return {
tempStations,
tempReadings,
windReadings,
items,
forecasts,
areaMetadata,
latestTempReading,
latestWindReading,
latestReadingTime,
tempStationMap,
windDataMap,
getCardinalDirection,
forecastsWithLocation
};
}, [actualTempData, actualWindData, actualForecastData]);
if (!processedData) {
return <Text>No map data available</Text>;
}
const latestTempReading = tempReadings[0];
const latestWindReading = windReadings[0];
if (!latestTempReading || !latestTempReading.data) {
return <Text>No map data available</Text>;
}
const latestReadingTime = formatTime(latestTempReading.timestamp);
const tempStationMap = new Map(tempStations.map((st: AirTempStation) => [st.id, st]));
const windDataMap = new Map(latestWindReading?.data?.map((rd: WindDataPoint) => [rd.stationId, rd.value]) || []);
const getCardinalDirection = (deg: number | null | undefined) => {
if (deg === null || deg === undefined) return 'N/A';
const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
const idx = Math.round((deg % 360) / 22.5) % 16;
return dirs[idx];
};
const forecastsWithLocation = forecasts
.map(f => ({
area: f.area,
forecast: f.forecast,
location: areaMetadata.find(a => a.name === f.area)?.label_location,
}))
.filter(f => f.location) as ForecastWithLocation[];
const {
tempStations,
latestTempReading,
latestWindReading,
latestReadingTime,
tempStationMap,
windDataMap,
getCardinalDirection,
forecastsWithLocation
} = processedData;
return (
<Box className="weather-map-shell">
@@ -249,4 +277,4 @@ const WeatherMap: React.FC<WeatherMapProps> = ({ forecastData, tempData, windDat
);
};
export default WeatherMap;
export default React.memo(WeatherMap);
+26 -12
View File
@@ -1,12 +1,14 @@
import React from 'react';
import { Container, Box, Heading, Text, Separator, Tabs as ChakraTabs } from '@chakra-ui/react';
import { createFileRoute } from '@tanstack/react-router';
import TwoHourForecast from '@/components/weather/TwoHourForecast';
import AirTemperature from '@/components/weather/AirTemperature';
import FourDayOutlook from '@/components/weather/FourDayOutlook';
import WeatherStatistics from '@/components/weather/WeatherStatistics';
import WindDirection from '@/components/weather/WindDirection';
import WeatherMap from '@/components/weather/WeatherMap';
// Lazy-load components to improve initial bundle size
const TwoHourForecast = React.lazy(() => import('@/components/weather/TwoHourForecast'));
const AirTemperature = React.lazy(() => import('@/components/weather/AirTemperature'));
const FourDayOutlook = React.lazy(() => import('@/components/weather/FourDayOutlook'));
const WeatherStatistics = React.lazy(() => import('@/components/weather/WeatherStatistics'));
const WindDirection = React.lazy(() => import('@/components/weather/WindDirection'));
const WeatherMap = React.lazy(() => import('@/components/weather/WeatherMap'));
const tabValues = {
twoHour: 'twoHourForecast',
@@ -44,22 +46,34 @@ function WeatherHub() {
<ChakraTabs.ContentGroup>
<ChakraTabs.Content value={tabValues.twoHour}>
<TwoHourForecast />
<React.Suspense fallback={<Box>Loading forecast...</Box>}>
<TwoHourForecast />
</React.Suspense>
</ChakraTabs.Content>
<ChakraTabs.Content value={tabValues.airTemp}>
<AirTemperature />
<React.Suspense fallback={<Box>Loading temperature...</Box>}>
<AirTemperature />
</React.Suspense>
</ChakraTabs.Content>
<ChakraTabs.Content value={tabValues.fourDay}>
<FourDayOutlook />
<React.Suspense fallback={<Box>Loading forecast...</Box>}>
<FourDayOutlook />
</React.Suspense>
</ChakraTabs.Content>
<ChakraTabs.Content value={tabValues.stats}>
<WeatherStatistics />
<React.Suspense fallback={<Box>Loading statistics...</Box>}>
<WeatherStatistics />
</React.Suspense>
</ChakraTabs.Content>
<ChakraTabs.Content value={tabValues.windDir}>
<WindDirection />
<React.Suspense fallback={<Box>Loading wind data...</Box>}>
<WindDirection />
</React.Suspense>
</ChakraTabs.Content>
<ChakraTabs.Content value={tabValues.weatherMap}>
<WeatherMap />
<React.Suspense fallback={<Box>Loading map...</Box>}>
<WeatherMap />
</React.Suspense>
</ChakraTabs.Content>
</ChakraTabs.ContentGroup>
</ChakraTabs.Root>
+19 -1
View File
@@ -10,7 +10,13 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
plugins: [react(), TanStackRouterVite()],
plugins: [
react({
// Enable JSX runtime for React 19
jsxRuntime: "automatic"
}),
TanStackRouterVite(),
],
server: {
allowedHosts: [
"dev.lan",
@@ -31,4 +37,16 @@ export default defineConfig({
},
},
},
build: {
// Enable tree-shaking by default
rollupOptions: {
output: {
// Split vendor code into separate chunks for better caching
manualChunks: {
vendor: ["react", "react-dom", "@tanstack/react-query", "@chakra-ui/react"],
leaflet: ["leaflet", "react-leaflet"],
},
},
},
},
})