RavelloH's Blog

LOADing...



React实现图片点击放大

react-image-click-to-zoom

技术/设计 5495

nextjsreactjavascriptimage


前言

很久之前我就发现知乎网页版的图片点击放大效果很有趣,于是最近趁着重构博客的pjax部分写了一个React组件,效果还不错。

效果

知乎与我的效果对比 知乎与我的效果对比
你可以直接点击图片试试,如果gif图看起来比较伤眼可以试试下面这个
Vampire-Survivors-2025_2_4-23_42_23 Vampire-Survivors-2025_2_4-23_42_23

使用

// ImageZoom.jsx
'use client';

import { useEffect } from 'react';

const styles = {
    imgPlaceholder: {
        display: 'inline-block'
    },
    expandedImage: {
        position: 'fixed',
        zIndex: 1000,
        cursor: 'zoom-out',
        maxWidth: 'none',
        maxHeight: 'none',
        transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
        objectFit: 'contain',
        transformOrigin: 'top left'
    },
    overlay: {
        position: 'fixed',
        top: 0,
        left: 0,
        right: 0,
        bottom: 0,
        backgroundColor: 'rgba(0, 0, 0, 0)',
        zIndex: 999,
        cursor: 'zoom-out',
        transition: 'opacity 0.3s ease'
    },
    zoomableImg: {
        cursor: 'zoom-in'
    }
};

export default function ImageZoom() {
    useEffect(() => {
        function handleImageClick(img) {
            if (document.querySelector('.image-zoom-overlay')) {
                return;
            }

            const rect = img.getBoundingClientRect();
      
            const overlay = document.createElement('div');
            overlay.className = 'image-zoom-overlay';
            Object.assign(overlay.style, styles.overlay);
      
            const placeholder = document.createElement('div');
            Object.assign(placeholder.style, {
                width: rect.width + 'px',
                height: rect.height + 'px',
                ...styles.imgPlaceholder
            });
      
            const clone = img.cloneNode(true);
            clone.className = 'image-zoom-clone';
            Object.assign(clone.style, {
                ...styles.expandedImage,
                left: rect.left + 'px',
                top: rect.top + 'px',
                width: rect.width + 'px',
                height: rect.height + 'px',
                transform: 'none',
                opacity: '1'
            });
      
            document.body.appendChild(overlay);
            document.body.appendChild(clone);
            img.parentNode.insertBefore(placeholder, img);
            img.style.display = 'none';
      
            clone.offsetHeight;
      
            requestAnimationFrame(() => {
                const maxWidth = window.innerWidth * 0.9;
                const maxHeight = window.innerHeight * 0.9;
                const scale = Math.min(
                    maxWidth / rect.width,
                    maxHeight / rect.height,
                    3
                );
          
                const scaledWidth = rect.width * scale;
                const scaledHeight = rect.height * scale;
          
                const translateX = (window.innerWidth - scaledWidth) / 2 - rect.left;
                const translateY = (window.innerHeight - scaledHeight) / 2 - rect.top;
          
                Object.assign(clone.style, {
                    transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`
                });
            });
      
            const closeImage = () => {
                Object.assign(clone.style, {
                    transform: 'none'
                });
                overlay.style.opacity = '0';
          
                setTimeout(() => {
                    img.style.display = '';
                    placeholder.remove();
                    clone.remove();
                    overlay.remove();
                }, 300);
            };
      
            clone.onclick = closeImage;
            overlay.onclick = closeImage;
        }

        document.querySelectorAll('img[data-zoomable]').forEach(img => {
            img.removeEventListener('click', () => handleImageClick(img));
        });

        const images = document.querySelectorAll('img[data-zoomable]');
        images.forEach(img => {
            Object.assign(img.style, styles.zoomableImg);
            img.addEventListener('click', () => handleImageClick(img));
        });

        return () => {
            images.forEach(img => {
                img.removeEventListener('click', () => handleImageClick(img));
            });
        };
    }, []);

    return null;
}

将其置于个jsx文件中,然后直接在要缩放的页面引用就可以,位置无所谓。
注意应给要缩放的图片加上data-zoomable属性,举例来说我用的MarkdownIt渲染,更改image的渲染逻辑如下:

md.renderer.rules.image = function (tokens, idx, options, env, self) {
    const src = tokens[idx].attrGet('src');
    const alt = tokens[idx].content;
    return `
        <div class="imgbox">
            <img src="${src}" alt="${alt}" loading="lazy" data-zoomable="true">
            <span>${alt}</span>
        </div>
    `;
};

原理

原理上也比较简单,使用 useEffect,在组件挂载后为所有具有 data-zoomable 属性的图片绑定点击事件。当图片被点击时,会触发放大逻辑。
点击图片时,首先判断是否已有放大效果(通过检查是否存在 overlay 层),避免沟槽的react在开发模式下触发两次。然后获取被点击图片的位置和尺寸信息,用来计算动画起始状态。

这里不得不吐槽一下React的这个逆天机制了,会在React.StrictMode下使用React.useState等方法时渲染两次,具体来说是这样的:

https://react.docschina.org/learn/keeping-components-pure#detecting-impure-calculations-with-strict-mode https://react.docschina.org/learn/keeping-components-pure#detecting-impure-calculations-with-strict-mode
当时我看着自己的code很久也没觉得自己有写什么触发两次的逻辑,后来发现居然是react的锅。

说回正题,若未被触发,则创建一个overlay透明遮罩,一个用于原位置占位的placeholder,和一个用于真正显示放大照片的clone,随后通过 requestAnimationFrame 延迟一帧执行动画,使 cloned 图片从原始位置平滑缩放到居中并放大状态。动画通过 CSS 的 transform 属性实现(平移+缩放)。
随后点击图片或者遮罩就会将其反转动画,复原图片了。

INFO

框架状态


URL:
来源: ---
此页访问量:
此页访问人数:
此页平均滞留:
网络连接状态: 离线
Cookie状态: 已禁用
页面加载状态:
站点运行时长: ---

00:00


    无正在播放的音乐
    00:00/00:00


    账号
    User avatar
    未登录未设置描述...
    尚未登录,部分功能受限
    立刻 登录 注册
     未登录
    刷新进程离线
    当前未存储有效的TOKEN

    通知中心

    总计0条通知,0条未读通知
    当前未登录,无法查看通知


    - Mind stuff, that's what they say when the verses fly -