add lazy loading attributes to images and fix potential runaway timers in main page scroll logic

This commit is contained in:
2026-05-01 17:23:27 +08:00
parent efa37da996
commit 872423cbdd
8 changed files with 52 additions and 26 deletions

View File

@@ -1,5 +1,5 @@
import { Box, Image, Button, HStack, VStack, Text } from '@chakra-ui/react'
import { useState, useEffect, CSSProperties } from 'react'
import { useState, useEffect, useMemo, useRef, CSSProperties } from 'react'
interface CyclingImageProps {
src: string
@@ -49,6 +49,12 @@ const CyclingImage = ({
const [currentIntensity, setCurrentIntensity] = useState(intensity)
const [currentImage, setCurrentImage] = useState(src)
// Keep refs to latest src/src2 so the setInterval callback is never stale
const srcRef = useRef(src)
const src2Ref = useRef(src2)
useEffect(() => { srcRef.current = src }, [src])
useEffect(() => { src2Ref.current = src2 }, [src2])
useEffect(() => {
setSpeed(cycleDuration)
}, [cycleDuration])
@@ -58,11 +64,12 @@ const CyclingImage = ({
if (!src2 || !isPlaying) return
const interval = setInterval(() => {
setCurrentImage(prev => prev === src ? src2 : src)
// Use refs to read latest prop values, avoiding stale closures
setCurrentImage(prev => prev === srcRef.current ? src2Ref.current! : srcRef.current)
}, speed * 1000)
return () => clearInterval(interval)
}, [src, src2, speed, isPlaying])
}, [src2, speed, isPlaying])
const togglePlayPause = () => {
setIsPlaying(!isPlaying)
@@ -76,9 +83,10 @@ const CyclingImage = ({
setCurrentIntensity(value)
}
// CSS keyframes animation
// CSS keyframes animation — memoized so the <style> tag is only
// re-written when the intensity value actually changes.
const animationName = 'lightToDarkCycle'
const animationStyle = `
const animationStyle = useMemo(() => `
@keyframes ${animationName} {
0% {
filter: brightness(${1 - currentIntensity * 1.5});
@@ -90,7 +98,7 @@ const CyclingImage = ({
filter: brightness(${1 - currentIntensity * 1.5});
}
}
`
`, [currentIntensity])
return (
<>

View File

@@ -208,6 +208,7 @@ function Advantages() {
src='/images/new/heart.webp'
title="獅球嘜營萃護心油"
alt="獅球嘜營萃護心油"
loading="lazy"
w={{ base: '75vw', sm: '75vw', md: '40vw', lg: '40vw', xl: '40vw' }}
/>

View File

@@ -1,6 +1,6 @@
import { Box, Image, Stack, Text } from '@chakra-ui/react'
import CyclingImage from './CyclingImage'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { motion } from 'framer-motion'
const MotionImage = motion.create(Image)
@@ -9,6 +9,7 @@ const MotionText = motion.create(Text)
function Hero1() {
const [headerHeight, setHeaderHeight] = useState(80);
const bigWarningSize = { base: "17vw", sm: "15vw", md: "9vw", lg: "9vw", xl: "8vw" };
const delayedUpdateTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
const updateHeaderHeight = () => {
@@ -20,13 +21,15 @@ function Hero1() {
};
const delayedUpdate = () => {
setTimeout(updateHeaderHeight, 100);
clearTimeout(delayedUpdateTimerRef.current);
delayedUpdateTimerRef.current = setTimeout(updateHeaderHeight, 100);
};
delayedUpdate();
const resizeObserver = new ResizeObserver(() => {
setTimeout(updateHeaderHeight, 50);
clearTimeout(delayedUpdateTimerRef.current);
delayedUpdateTimerRef.current = setTimeout(updateHeaderHeight, 50);
});
const header = document.querySelector('[data-header="true"]');
@@ -38,6 +41,7 @@ function Hero1() {
return () => {
window.removeEventListener('resize', delayedUpdate);
clearTimeout(delayedUpdateTimerRef.current);
resizeObserver.disconnect();
};
}, []);

View File

@@ -34,6 +34,7 @@ function Hero2() {
left="50%"
title="獅球嘜營萃護心油"
alt="獅球嘜營萃護心油"
loading="lazy"
top={{ base: "10px", sm: "20px", md: "30px", lg: "30px", xl: "40px" }}
w={{ base: "70vw", sm: "70vw", md: "35vw", lg: "35vw", xl: "28vw" }}
initial={{ opacity: 0, y: -30, x: '-50%' }}
@@ -45,6 +46,7 @@ function Hero2() {
src="/images/new/hero2subtitle.webp"
position={'absolute'}
zIndex={1}
loading="lazy"
top={{ base: "35vw", sm: "35vw", md: "17vw", lg: "15vw", xl: "12vw" }}
right={{ base: "55vw", sm: "55vw", md: "24vw", lg: "25vw", xl: "30vw" }}
w={{ base: "34vw", sm: "34vw", md: "22vw", lg: "22vw", xl: "18vw" }}
@@ -59,6 +61,7 @@ function Hero2() {
alt="獅球嘜營萃護心油"
title="獅球嘜營萃護心油"
zIndex={0}
loading="lazy"
position={'absolute'}
left="50%"
bottom={0}

View File

@@ -85,6 +85,7 @@ function Info() {
src='/images/new/info_title.webp'
w={{ base: '90vw', sm: '80vw', md: '45vw', lg: '35vw', xl: '22vw' }}
mt={'50px'}
loading="lazy"
initial={{ opacity: 0, y: -30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.3 }}
@@ -156,7 +157,7 @@ function Info() {
src={infoData.find(info => info.id === selectedInfo)?.image}
w="100%"
p={5}
loading="lazy"
/>
<Box px={{ base: 5, sm: 5, md: 6, lg: 5, xl: 5 }}
pb={{ base: 5, sm: 5, md: 6, lg: 9, xl: 9 }}>

View File

@@ -68,6 +68,7 @@ function OilInfo() {
src='/images/cookmethods.webp'
alt='烹調方法'
title='烹調方法'
loading="lazy"
w={{ base: '100%', sm: '90%', md: '85%' }}
initial={{ opacity: 0, scale: 0.8 }}
animate={isMainInView ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.8 }}
@@ -109,6 +110,7 @@ function OilInfo() {
<MotionImage
src='/images/new/buttons.webp'
w={{ base: '100%', sm: '100%', md: '90%' }}
loading="lazy"
initial={{ opacity: 0, scale: 0.8 }}
animate={isMainInView ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.8 }}
transition={{ duration: 0.5, delay: 1.0, ease: "easeOut" }}

View File

@@ -91,7 +91,11 @@ function Truth() {
// Parse XFBML when SDK is loaded
const handleSDKLoad = () => {
if ((window as any).FB && fbContainerRef.current) {
try {
(window as any).FB.XFBML.parse(fbContainerRef.current);
} catch (_e) {
// FB SDK may fail (e.g. blocked by ad-blockers) — ignore silently
}
}
};

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import { useRouterState } from '@tanstack/react-router'
import Hero1 from '../components/new_ui/hero1'
import Hero2 from '../components/new_ui/hero2'
@@ -16,23 +16,26 @@ import Header from '@/components/header'
function MainPage() {
const { location } = useRouterState()
// Refs must live at component top level (Rules of Hooks).
// Storing timer IDs in refs means the cleanup closure always reads the
// latest value, preventing runaway timers after the component unmounts.
const animationFrameRef = useRef(0)
const retryTimeoutRef = useRef<number | undefined>(undefined)
useEffect(() => {
if (location.pathname !== '/40plus') {
return
}
let animationFrame = 0
let retryTimeout: number | undefined
// Replay pending section scroll requests written to sessionStorage
const clearTimers = () => {
if (animationFrame) {
window.cancelAnimationFrame(animationFrame)
animationFrame = 0
if (animationFrameRef.current) {
window.cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = 0
}
if (retryTimeout) {
window.clearTimeout(retryTimeout)
retryTimeout = undefined
if (retryTimeoutRef.current) {
window.clearTimeout(retryTimeoutRef.current)
retryTimeoutRef.current = undefined
}
}
@@ -44,17 +47,17 @@ function MainPage() {
if (element) {
window.sessionStorage.removeItem('pendingScrollSection')
element.scrollIntoView({ behavior: 'smooth' })
animationFrame = 0
retryTimeout = undefined
animationFrameRef.current = 0
retryTimeoutRef.current = undefined
return
}
retryTimeout = window.setTimeout(() => {
animationFrame = window.requestAnimationFrame(scrollToTarget)
retryTimeoutRef.current = window.setTimeout(() => {
animationFrameRef.current = window.requestAnimationFrame(scrollToTarget)
}, 50)
}
animationFrame = window.requestAnimationFrame(scrollToTarget)
animationFrameRef.current = window.requestAnimationFrame(scrollToTarget)
}
const runScroll = () => {