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


使用
// 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
等方法时渲染两次,具体来说是这样的:

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