随着社交媒体的深度渗透,朋友圈、微博、Instagram 等平台已成为用户展示生活、分享瞬间的核心场景。其中,"九宫格"排版形式凭借其规整的视觉美感和内容叙事性,成为年轻用户(尤其是女性群体)高频使用的图片发布方式。
在接下来的内容中,我们将使用 NextJs 结合 sharp 来实现图片裁剪的功能。
编写基本页面
首先我们将编写一个图片裁剪的功能,以支持用户来生成不同规格的图片,例如 3x3、2x2 等格式。
如下代码所示:
tsx
"use client";
import { useState, useRef } from "react";
const Home = () => {
const [rows, setRows] = useState(3);
const [columns, setColumns] = useState(3);
const [image, setImage] = useState<string | null>(null);
const [splitImages, setSplitImages] = useState<string[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const imageRef = useRef<HTMLImageElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileUpload = (file: File) => {
if (!file.type.startsWith("image/")) {
alert("请上传图片文件");
return;
}
// 先重置状态
setImage(null);
setSplitImages([]);
setImageLoaded(false);
const reader = new FileReader();
reader.onload = (e) => {
if (e.target?.result) {
setImage(e.target.result as string);
}
};
reader.readAsDataURL(file);
};
// 修改图片加载完成的处理函数
const handleImageLoad = () => {
console.log("Image loaded"); // 添加日志以便调试
setImageLoaded(true);
};
// 渲染切割辅助线
const renderGuideLines = () => {
if (!image || !imageRef.current || !imageLoaded) return null;
const commonLineStyles = "bg-blue-500/50 absolute pointer-events-none";
const imgRect = imageRef.current.getBoundingClientRect();
const containerRect =
imageRef.current.parentElement?.getBoundingClientRect();
if (!containerRect) return null;
const imgStyle = {
left: `${imgRect.left - containerRect.left}px`,
top: `${imgRect.top - containerRect.top}px`,
width: `${imgRect.width}px`,
height: `${imgRect.height}px`,
};
return (
<div className="absolute pointer-events-none" style={imgStyle}>
{/* 垂直线 */}
{Array.from({ length: Math.max(0, columns - 1) }).map((_, i) => (
<div
key={`v-${i}`}
className={`${commonLineStyles} top-0 bottom-0 w-[1px] md:w-[2px] backdrop-blur-sm`}
style={{
left: `${((i + 1) * 100) / columns}%`,
transform: "translateX(-50%)",
}}
/>
))}
{/* 水平线 */}
{Array.from({ length: Math.max(0, rows - 1) }).map((_, i) => (
<div
key={`h-${i}`}
className={`${commonLineStyles} left-0 right-0 h-[1px] md:h-[2px] backdrop-blur-sm`}
style={{
top: `${((i + 1) * 100) / rows}%`,
transform: "translateY(-50%)",
}}
/>
))}
</div>
);
};
// 处理图片切割
const handleSplitImage = async () => {
if (!image) return;
setIsProcessing(true);
try {
const response = await fetch(image);
const blob = await response.blob();
const file = new File([blob], "image.jpg", { type: blob.type });
const formData = new FormData();
formData.append("image", file);
formData.append("rows", rows.toString());
formData.append("columns", columns.toString());
const res = await fetch("/api/split-image", {
method: "POST",
body: formData,
});
const data = await res.json();
if (data.error) {
throw new Error(data.error);
}
setSplitImages(data.pieces);
} catch (error) {
console.error("Failed to split image:", error);
alert("图片切割失败,请重试");
} finally {
setIsProcessing(false);
}
};
// 添加下载单个图片的函数
const handleDownloadSingle = async (imageUrl: string, index: number) => {
try {
const response = await fetch(imageUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `piece_${index + 1}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error("下载失败:", error);
alert("下载失败,请重试");
}
};
// 添加打包下载所有图片的函数
const handleDownloadAll = async () => {
try {
// 如果没有 JSZip,需要先动态导入
const JSZip = (await import("jszip")).default;
const zip = new JSZip();
// 添加所有图片到 zip
const promises = splitImages.map(async (imageUrl, index) => {
const response = await fetch(imageUrl);
const blob = await response.blob();
zip.file(`piece_${index + 1}.png`, blob);
});
await Promise.all(promises);
// 生成并下载 zip 文件
const content = await zip.generateAsync({ type: "blob" });
const url = window.URL.createObjectURL(content);
const link = document.createElement("a");
link.href = url;
link.download = "split_images.zip";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error("打包下载失败:", error);
alert("打包下载失败,请重试");
}
};
// 修改预览区域的渲染函数
const renderPreview = () => {
if (!image) {
return <p className="text-gray-400">切割后的图片预览</p>;
}
if (isProcessing) {
return <p className="text-gray-400">正在处理中...</p>;
}
if (splitImages.length > 0) {
return (
<div className="relative w-full h-full flex items-center justify-center">
<div
className="grid gap-[3px] bg-[#242c3e]"
style={{
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
width: imageRef.current?.width || "100%",
height: imageRef.current?.height || "100%",
maxWidth: "100%",
maxHeight: "100%",
}}
>
{splitImages.map((src, index) => (
<div key={index} className="relative group">
<img
src={src}
alt={`切片 ${index + 1}`}
className="w-full h-full object-cover"
/>
<button
onClick={() => handleDownloadSingle(src, index)}
className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity"
>
<svg
className="w-6 h-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</button>
</div>
))}
</div>
</div>
);
}
return <p className="text-gray-400">点击切割按钮开始处理</p>;
};
return (
<>
<div className="fixed inset-0 bg-[#0B1120] -z-10" />
<main className="min-h-screen w-full py-16 md:py-20">
<div className="container mx-auto px-4 sm:px-6 max-w-7xl">
{/* 标题区域 */}
<div className="text-center mb-12 md:mb-16">
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-4 animate-fade-in">
<span className="bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 text-transparent bg-clip-text bg-[size:400%] animate-gradient">
图片切割工具
</span>
</h1>
<p className="text-gray-400 text-base md:text-lg max-w-2xl mx-auto animate-fade-in-up">
上传一张图片,快速将其切割成网格布局,支持自定义行列数。
</p>
</div>
{/* 图片区域 - 调整高度和响应式布局 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 md:gap-8 mb-12 md:mb-16">
{/* 上传区域 - 调整高度 */}
<div className="relative h-[400px] md:h-[500px] lg:h-[600px] bg-[#1a2234] rounded-xl overflow-hidden">
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileUpload(file);
}}
id="imageUpload"
/>
<label
htmlFor="imageUpload"
className="absolute inset-0 flex flex-col items-center justify-center cursor-pointer"
>
{image ? (
<div className="relative w-full h-full flex items-center justify-center">
<img
ref={imageRef}
src={image}
alt="上传的图片"
className="max-w-full max-h-full object-contain"
onLoad={handleImageLoad}
key={image}
/>
{renderGuideLines()}
</div>
) : (
<>
<div className="p-4 rounded-full bg-[#242c3e] mb-4">
<svg
className="w-8 h-8 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<p className="text-gray-400">点击或拖拽图片到这里上传</p>
</>
)}
</label>
</div>
{/* 预览区域 - 调整高度 */}
<div className="h-[400px] md:h-[500px] lg:h-[600px] bg-[#1a2234] rounded-xl flex items-center justify-center">
{renderPreview()}
</div>
</div>
{/* 控制器 - 添加上下边距的容器 */}
<div className="py-4 md:py-6">
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 md:gap-6">
<div className="flex flex-col sm:flex-row items-center gap-4 w-full sm:w-auto">
<div className="bg-[#1a2234] rounded-xl px-5 py-2.5 flex items-center gap-4 border border-[#242c3e] w-full sm:w-auto">
<span className="text-gray-400 font-medium min-w-[40px]">
行数
</span>
<div className="flex items-center gap-2 bg-[#242c3e] rounded-lg p-1 flex-1 sm:flex-none justify-center">
<button
className="w-8 h-8 flex items-center justify-center text-white rounded-lg hover:bg-blue-500 transition-colors"
onClick={() => setRows(Math.max(1, rows - 1))}
>
-
</button>
<span className="text-white min-w-[32px] text-center font-medium">
{rows}
</span>
<button
className="w-8 h-8 flex items-center justify-center text-white rounded-lg hover:bg-blue-500 transition-colors"
onClick={() => setRows(rows + 1)}
>
+
</button>
</div>
</div>
<div className="bg-[#1a2234] rounded-xl px-5 py-2.5 flex items-center gap-4 border border-[#242c3e] w-full sm:w-auto">
<span className="text-gray-400 font-medium min-w-[40px]">
列数
</span>
<div className="flex items-center gap-2 bg-[#242c3e] rounded-lg p-1 flex-1 sm:flex-none justify-center">
<button
className="w-8 h-8 flex items-center justify-center text-white rounded-lg hover:bg-blue-500 transition-colors"
onClick={() => setColumns(Math.max(1, columns - 1))}
>
-
</button>
<span className="text-white min-w-[32px] text-center font-medium">
{columns}
</span>
<button
className="w-8 h-8 flex items-center justify-center text-white rounded-lg hover:bg-blue-500 transition-colors"
onClick={() => setColumns(columns + 1)}
>
+
</button>
</div>
</div>
<button
className="px-5 py-2.5 bg-[#1a2234] text-gray-400 rounded-xl hover:bg-[#242c3e] hover:text-white transition-all font-medium border border-[#242c3e] w-full sm:w-auto"
onClick={() => {
setRows(3);
setColumns(3);
}}
>
重置
</button>
</div>
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
<button
className="px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl hover:from-blue-600 hover:to-blue-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 font-medium shadow-lg shadow-blue-500/20 w-full sm:w-auto"
disabled={!image || isProcessing}
onClick={handleSplitImage}
>
{isProcessing ? "处理中..." : "切割图片"}
</button>
<button
className="px-6 py-3 bg-gradient-to-r from-red-500 to-red-600 text-white rounded-xl hover:from-red-600 hover:to-red-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed font-medium shadow-lg shadow-red-500/20 w-full sm:w-auto"
onClick={() => {
setImage(null);
setSplitImages([]);
setImageLoaded(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}}
disabled={!image}
>
清除
</button>
</div>
{/* 下载按钮 */}
{splitImages.length > 0 && (
<button
onClick={handleDownloadAll}
className="px-6 py-3 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-xl hover:from-green-600 hover:to-green-700 transition-all font-medium shadow-lg shadow-green-500/20 flex items-center justify-center gap-2 w-full sm:w-auto"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
打包下载
</button>
)}
</div>
</div>
{/* 底部留白 */}
<div className="h-16 md:h-20"></div>
</div>
</main>
</>
);
};
export default Home;
在上面的代码中,当用户上传图片兵当图片加载完成之后,renderGuideLines 方法会绘制图片的切割网格,显示垂直和水平的分隔线,便于用户预览切割后的效果。
它会根据用户设置的行数(rows)和列数(columns),这些切割线将被动态渲染。用户设置完行列数后,点击 "切割图片" 按钮,会触发 handleSplitImage 函数,将图片传递到后端进行切割。图片会被发送到后端接口 /api/split-image,并返回切割后的图片数据(即每个小图的 URL)。
最终 ui 效果如下图所示:
设计 API
前面的内容中,我们已经编写了前端的 ui,接下来我们要设计我们的 api 接口以支持前端页面调用。
首先我们要先知道一个概念,sharp 是一个高性能的 Node.js 图像处理库,支持各种常见的图像操作,如裁剪、调整大小、旋转、转换格式等。它基于 libvips(一个高效的图像处理库),与其他一些图像处理库相比,它的处理速度更快,内存消耗也更低。
而 Next.js 是一个服务器端渲染(SSR)的框架,可以通过 API 路由来处理用户上传的文件。在 API 路由中使用 sharp 可以确保图像在服务器端得到处理,而不是在客户端进行,这样可以减轻客户端的负担,并且保证图像在服务器上处理完成后再发送到客户端,从而提高页面加载速度。
如下代码所示:
ts
import sharp from "sharp";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
try {
const data = await request.formData();
const file = data.get("image") as File;
const rows = Number(data.get("rows"));
const columns = Number(data.get("columns"));
if (!file || !rows || !columns) {
return NextResponse.json(
{ error: "Missing required parameters" },
{ status: 400 }
);
}
if (!file.type.startsWith("image/")) {
return NextResponse.json({ error: "Invalid file type" }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
if (!buffer || buffer.length === 0) {
return NextResponse.json(
{ error: "Invalid image buffer" },
{ status: 400 }
);
}
const image = sharp(buffer);
const metadata = await image.metadata();
if (!metadata.width || !metadata.height) {
return NextResponse.json(
{ error: "Invalid image metadata" },
{ status: 400 }
);
}
console.log("Processing image:", {
width: metadata.width,
height: metadata.height,
format: metadata.format,
rows,
columns,
});
const pieces: string[] = [];
const width = metadata.width;
const height = metadata.height;
const pieceWidth = Math.floor(width / columns);
const pieceHeight = Math.floor(height / rows);
if (pieceWidth <= 0 || pieceHeight <= 0) {
return NextResponse.json(
{ error: "Invalid piece dimensions" },
{ status: 400 }
);
}
for (let i = 0; i < rows; i++) {
for (let j = 0; j < columns; j++) {
const left = j * pieceWidth;
const top = i * pieceHeight;
const currentWidth = j === columns - 1 ? width - left : pieceWidth;
const currentHeight = i === rows - 1 ? height - top : pieceHeight;
try {
const piece = await image
.clone()
.extract({
left,
top,
width: currentWidth,
height: currentHeight,
})
.toBuffer();
pieces.push(
`data:image/${metadata.format};base64,${piece.toString("base64")}`
);
} catch (err) {
console.error("Error processing piece:", { i, j, err });
throw err;
}
}
}
return NextResponse.json({ pieces });
} catch (error) {
console.error("Error processing image:", error);
return NextResponse.json(
{
error: "Failed to process image",
details: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 }
);
}
}
export const config = {
api: {
bodyParser: {
sizeLimit: "10mb",
},
},
};
在上面的这些代码中,它的具体流程如下:
-
接收请求:首先通过 POST 方法接收包含图片和切割参数(行数和列数)的表单数据。request.formData() 用来解析表单数据并提取文件和参数。
-
参数验证:检查文件类型是否为图片,确保上传数据完整且有效。如果缺少必需的参数或上传的不是图片,返回相应的错误信息。
-
图片处理:通过 sharp 库将图片数据转换为可操作的 Buffer,然后获取图片的元数据(如宽度、高度、格式)。如果图片的元数据无效或获取不到,返回错误。
-
计算切割尺寸:根据用户输入的行数和列数计算每个小块的宽高,并检查计算出来的尺寸是否有效。如果计算出的尺寸不合适,返回错误。
-
图片切割:使用 sharp 的 extract 方法对图片进行切割。每次提取一个小块后,将其转换为 base64 编码的字符串,并保存到 pieces 数组中。
-
返回结果:成功处理后,将切割后的图片数据作为 JSON 响应返回,每个图片切块以 base64 编码形式存储。若遇到错误,捕获并返回详细的错误信息。
-
配置:通过 config 配置,设置请求体的最大大小限制为 10MB,防止上传过大的文件导致请求失败。
这里的代码完成之后,我们还要设计一下 next.config.mjs 文件,如下:
js
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config) => {
config.externals = [...config.externals, "sharp"];
return config;
},
};
export default nextConfig;
当我们点击切割图片的时候,最终生成的效果如下图所示:
总结
如果你想参与进来开发或者想进群学习,可以添加我微信 yunmz777
,后面还会有很多需求,等这个项目完成之后还会有很多新的并且很有趣的开源项目等着你。
如果该项目对你有帮助或者对这个项目感兴趣,欢迎 Star⭐️⭐️⭐️
最后再来提一些这两个开源项目,它们都是我们目前正在维护的开源项目:
🐒🐒🐒