图片裁剪是现代 Web 应用中常见的功能需求,无论是社交媒体平台、电商网站还是个人博客,用户经常需要对上传的图片进行裁剪以获得最佳显示效果。本文将介绍如何使用纯前端技术实现一个功能完整的图片裁剪工具,无需后端支持即可在浏览器中完成图片裁剪操作。

实现原理概述

我们的图片裁剪工具基于 HTML5、CSS3 和原生 JavaScript 实现,主要利用以下技术特性:

      使用 HTML5 的 File API 处理图片上传
      通过 CSS 的 clip-path 属性实现图片裁剪效果预览
      使用 Canvas API 生成最终裁剪结果
      结合鼠标事件实现裁剪框的拖拽和缩放交互

实现效果展示

完整功能代码

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>图片裁剪demo</title>
    <style type="text/css">
        body {
            background: #555;
        }

        #box {
            position: relative;
        }

        #img1 {
            opacity: 0.6;
            position: absolute;
            top: 0;
            left: 0;
            user-select: none;
        }

        #img2 {
            opacity: 1;
            position: absolute;
            top: 0;
            left: 0;
            user-select: none;
            /* clip: rect(0px,200px,200px,0px); */
            clip-path: polygon(0px 0px,
                    /* 左上角 */
                    200px 0px,
                    /* 右上角 */
                    200px 200px,
                    /* 右下角 */
                    0px 200px
                    /* 左下角 */
                );
        }

        #main {
            position: absolute;
            width: 200px;
            height: 200px;
            cursor: move;
            display: none;
        }

        .minDiv {
            position: absolute;
            width: 8px;
            height: 8px;
            background: #fff;
        }

        .left-up {
            top: -4px;
            left: -4px;
            cursor: nw-resize;
        }

        .up {
            left: 50%;
            margin-left: -4px;
            margin-top: -4px;
            cursor: n-resize;
        }

        .right-up {
            right: -4px;
            top: -4px;
            cursor: ne-resize;
        }

        .right {
            right: -4px;
            top: 50%;
            margin-top: -4px;
            cursor: e-resize;
        }

        .right-down {
            right: -4px;
            bottom: -4px;
            cursor: se-resize;
        }

        .down {
            bottom: -4px;
            right: 50%;
            margin-left: -4px;
            cursor: s-resize;
        }

        .left-down {
            left: -4px;
            bottom: -4px;
            cursor: sw-resize;
        }

        .left {
            left: -4px;
            top: 50%;
            margin-top: -4px;
            cursor: w-resize;
        }
    </style>
</head>

<body>
    <div class="contain">
        <input id="imgFile" type="file" name="imgFile">
        <button id="preview">预览裁剪结果</button>
        <div id="box">
            <img src="" id="img1">
            <img src="" id="img2">
            <div id="main">
                <div class="minDiv left-up"></div>
                <div class="minDiv up"></div>
                <div class="minDiv right-up"></div>
                <div class="minDiv right"></div>
                <div class="minDiv right-down"></div>
                <div class="minDiv down"></div>
                <div class="minDiv left-down"></div>
                <div class="minDiv left"></div>
            </div>
        </div>
    </div>

</body>

<script>
    const doms = {
        imgFile: document.getElementById('imgFile'),
        preview: document.getElementById('preview'),
        box: document.getElementById('box'),
        img1: document.getElementById('img1'),
        img2: document.getElementById('img2'),
        main: document.getElementById('main'),

        leftUp: document.querySelector('.left-up'),
        up: document.querySelector('.up'),
        rightUp: document.querySelector('.right-up'),
        right: document.querySelector('.right'),
        rightDown: document.querySelector('.right-down'),
        down: document.querySelector('.down'),
        leftDown: document.querySelector('.left-down'),
        left: document.querySelector('.left')
    }
    const dirDoms = [
        doms.leftUp,
        doms.up,
        doms.rightUp,
        doms.right,
        doms.rightDown,
        doms.down,
        doms.leftDown,
        doms.left
    ]

    // 封装:更新裁剪框位置和大小(带边界检查)
    function updateCropBoxPosition(left, top, width, height) {
        // console.log('left, top, width, height', left, top, width, height);

        // 边界检查
        const imgWidth = doms.img1.offsetWidth;
        const imgHeight = doms.img1.offsetHeight;

        // 设置最小尺寸限制
        const minWidth = 60;
        const minHeight = 60;

        // 确保宽度和高度不小于最小值
        if (width < minWidth) width = minWidth;
        if (height < minHeight) height = minHeight;

        // 防止左侧溢出
        if (left < 0) {
            left = 0;
        }

        // 防止右侧溢出
        if (left + width > imgWidth) {
            // 尝试调整位置保持宽度
            if (width <= imgWidth) {
                left = imgWidth - width;
            } else {
                // 如果宽度大于图片宽度,限制宽度并居中显示
                width = imgWidth;
                left = 0;
            }
        }

        // 防止顶部溢出
        if (top < 0) {
            top = 0;
        }

        // 防止底部溢出
        if (top + height > imgHeight) {
            // 尝试调整位置保持高度
            if (height <= imgHeight) {
                top = imgHeight - height;
            } else {
                // 如果高度大于图片高度,限制高度并居中显示
                height = imgHeight;
                top = 0;
            }
        }

        // 设置裁剪框样式
        doms.main.style.left = `${left}px`;
        doms.main.style.top = `${top}px`;
        doms.main.style.width = `${width}px`;
        doms.main.style.height = `${height}px`;

        // 更新裁剪路径
        doms.img2.style.clipPath = `polygon(
        ${left}px ${top}px,
        ${left + width}px ${top}px,
        ${left + width}px ${top + height}px,
        ${left}px ${top + height}px
     )`;
    }

    // 实现图片的上传
    doms.imgFile.onchange = function (e) {
        // 获取上传的图片    
        const file = e.target.files[0];
        // 创建一个文件读取器
        const reader = new FileReader();
        // 读取文件
        reader.readAsDataURL(file);
        // 读取文件成功时,将图片显示在img1和img2中
        reader.onload = function (e) {
            const imgUrl = e.target.result;
            // 设置图片 src 后,监听图片的 load 事件
            doms.img1.src = imgUrl;
            doms.img2.src = imgUrl;

            // 监听 img1 的 load 事件(确保两张图片共享同一 URL,避免重复加载)
            const img = new Image();
            img.src = imgUrl;
            img.onload = function () {
                // 此时图片已完全渲染,可获取正确尺寸
                const imgWidth = doms.img1.offsetWidth;
                const imgHeight = doms.img1.offsetHeight;

                // 初始化裁剪框
                const width = imgWidth / 2;
                const height = imgHeight / 2;
                const left = imgWidth / 4;
                const top = imgHeight / 4;

                updateCropBoxPosition(left, top, width, height);
                doms.main.style.display = 'block';
            };

            // const url = URL.createObjectURL(file);
            // doms.img1.src = url;
            // doms.img2.src = url;
        };
    };

    // 预览裁剪结果
    doms.preview.onclick = function () {
        // 获取裁剪框的位置和大小
        const left = doms.main.offsetLeft;
        const top = doms.main.offsetTop;
        const width = doms.main.offsetWidth;
        const height = doms.main.offsetHeight;
        // console.log(left, top, width, height);
        // 使用canvas将裁剪框中的图片裁剪出来
        const canvas = document.createElement('canvas');
        canvas.width = width;
        canvas.height = height;
        const ctx = canvas.getContext('2d');
        ctx.drawImage(doms.img1, left, top, width, height, 0, 0, width, height);
        // 将裁剪后的图片显示在页面上 不要被裁剪框遮挡
        canvas.style.position = 'absolute';
        canvas.style.left = doms.img1.offsetWidth + 50 + 'px';
        // 如果canvas已经存在,就删除
        if (document.querySelector('canvas')) {
            document.querySelector('canvas').remove();
        }
        document.body.appendChild(canvas);
        canvas.toBlob(function (blob) {
            // console.log(blob);
            const file = new File([blob], 'cut.png', { type: 'image/png' });
            console.log('file', file);
        })
    }

    // 实现裁剪框的拖拽效果
    doms.main.onmousedown = function (e) {
        // 鼠标按下时,获取鼠标的位置
        const startX = e.clientX;
        const startY = e.clientY;
        // console.log(startX, startY);
        // 裁剪框初始位置
        const startLeft = doms.main.offsetLeft;
        const startTop = doms.main.offsetTop;
        // 鼠标移动时,获取鼠标的位置
        document.onmousemove = function (e) {
            const moveX = e.clientX;
            const moveY = e.clientY;
            // 计算鼠标移动的距离
            const disX = moveX - startX;
            const disY = moveY - startY;
            // console.log(disX, disY);
            // 计算裁剪框的位置
            const left = startLeft + disX;
            const top = startTop + disY;
            updateCropBoxPosition(left, top, doms.main.offsetWidth, doms.main.offsetHeight);
        }
        // 鼠标抬起时,停止移动
        document.onmouseup = function () {
            document.onmousemove = null;
            document.onmouseup = null;
        }

    }
    // 实现裁剪框的缩放效果
    dirDoms.forEach(dirDom => {
        dirDom.onmousedown = function (e) {
            // 阻止父元素的事件
            e.stopPropagation();
            // 鼠标按下时,获取鼠标的位置
            const startX = e.clientX;
            const startY = e.clientY;
            // 裁剪框初始位置
            const startLeft = doms.main.offsetLeft;
            const startTop = doms.main.offsetTop;
            // 裁剪框初始大小
            const startWidth = doms.main.offsetWidth;
            const startHeight = doms.main.offsetHeight;
            // 鼠标移动时,获取鼠标的位置
            document.onmousemove = function (e) {
                const moveX = e.clientX;
                const moveY = e.clientY;
                // 计算鼠标移动的距离
                const disX = moveX - startX;
                const disY = moveY - startY;
                // 根据不同点计算裁剪框的位置和大小 
                let left;
                let top;
                let width;
                let height;
                if (dirDom === doms.leftUp) {
                    left = startLeft + disX;
                    top = startTop + disY;
                    width = startWidth - disX;
                    height = startHeight - disY;
                }
                else if (dirDom === doms.up) {
                    left = startLeft;
                    top = startTop + disY;
                    width = startWidth;
                    height = startHeight - disY;
                }
                else if (dirDom === doms.rightUp) {
                    left = startLeft;
                    top = startTop + disY;
                    width = startWidth + disX;
                    height = startHeight - disY;
                }
                else if (dirDom === doms.right) {
                    left = startLeft;
                    top = startTop;
                    width = startWidth + disX;
                    height = startHeight;
                }
                else if (dirDom === doms.rightDown) {
                    left = startLeft;
                    top = startTop;
                    width = startWidth + disX;
                    height = startHeight + disY;
                }
                else if (dirDom === doms.down) {
                    left = startLeft;
                    top = startTop;
                    width = startWidth;
                    height = startHeight + disY;
                }
                else if (dirDom === doms.leftDown) {
                    left = startLeft + disX;
                    top = startTop;
                    width = startWidth - disX;
                    height = startHeight + disY;
                }
                else if (dirDom === doms.left) {
                    left = startLeft + disX;
                    top = startTop;
                    width = startWidth - disX;
                    height = startHeight;
                }
                else {
                    left = startLeft;
                    top = startTop;
                    width = startWidth;
                    height = startHeight;
                }

                updateCropBoxPosition(left, top, width, height);
            }
            // 鼠标抬起时,停止移动
            document.onmouseup = function () {
                document.onmousemove = null;
                document.onmouseup = null;
            }
        }

    })


</script>

</html>

核心功能解析

1.双图片叠加预览技术

我们使用两张相同的图片叠加显示,底层图片 (img1) 设置为半透明作为背景参考,上层图片 (img2) 使用 clip-path 属性进行裁剪,从而实现裁剪效果的实时预览:

#img1 {
    opacity: 0.6;
    position: absolute;
    top: 0;
    left: 0;
}

#img2 {
    opacity: 1;
    position: absolute;
    top: 0;
    left: 0;
    clip-path: polygon(0px 0px, 200px 0px, 200px 200px, 0px 200px);
}

2.图片上传与预览

通过 HTML5 的 File API 实现图片上传功能,使用 FileReader 将图片转换为 DataURL 格式并显示在页面上:

doms.imgFile.onchange = function (e) {
    // 获取上传的图片    
    const file = e.target.files[0];
    // 创建一个文件读取器
    const reader = new FileReader();
    // 读取文件
    reader.readAsDataURL(file);
    // 读取文件成功时,将图片显示在img1和img2中
    reader.onload = function (e) {
        const imgUrl = e.target.result;
        // 设置图片 src 后,监听图片的 load 事件
        doms.img1.src = imgUrl;
        doms.img2.src = imgUrl;

        // 监听 img1 的 load 事件(确保两张图片共享同一 URL,避免重复加载)
        const img = new Image();
        img.src = imgUrl;
        img.onload = function () {
            // 此时图片已完全渲染,可获取正确尺寸
            const imgWidth = doms.img1.offsetWidth;
            const imgHeight = doms.img1.offsetHeight;

            // 初始化裁剪框
            const width = imgWidth / 2;
            const height = imgHeight / 2;
            const left = imgWidth / 4;
            const top = imgHeight / 4;

            updateCropBoxPosition(left, top, width, height);
            doms.main.style.display = 'block';
        };

        // const url = URL.createObjectURL(file);
        // doms.img1.src = url;
        // doms.img2.src = url;
    };
};

3.裁剪框的拖拽与缩放交互

核心就是确定裁剪框最终的left,top,width以及height的值,通过监听鼠标事件实现裁剪框的拖拽和缩放功能:

// 实现裁剪框的拖拽效果
doms.main.onmousedown = function (e) {
    // 鼠标按下时,获取鼠标的位置
    const startX = e.clientX;
    const startY = e.clientY;
    // console.log(startX, startY);
    // 裁剪框初始位置
    const startLeft = doms.main.offsetLeft;
    const startTop = doms.main.offsetTop;
    // 鼠标移动时,获取鼠标的位置
    document.onmousemove = function (e) {
        const moveX = e.clientX;
        const moveY = e.clientY;
        // 计算鼠标移动的距离
        const disX = moveX - startX;
        const disY = moveY - startY;
        // console.log(disX, disY);
        // 计算裁剪框的位置
        const left = startLeft + disX;
        const top = startTop + disY;
        updateCropBoxPosition(left, top, doms.main.offsetWidth, doms.main.offsetHeight);
    }
    // 鼠标抬起时,停止移动
    document.onmouseup = function () {
        document.onmousemove = null;
        document.onmouseup = null;
    }
}


// 实现裁剪框的缩放效果
dirDoms.forEach(dirDom => {
    dirDom.onmousedown = function (e) {
        // 阻止父元素的事件
        e.stopPropagation();
        // 鼠标按下时,获取鼠标的位置
        const startX = e.clientX;
        const startY = e.clientY;
        // 裁剪框初始位置
        const startLeft = doms.main.offsetLeft;
        const startTop = doms.main.offsetTop;
        // 裁剪框初始大小
        const startWidth = doms.main.offsetWidth;
        const startHeight = doms.main.offsetHeight;
        // 鼠标移动时,获取鼠标的位置
        document.onmousemove = function (e) {
            const moveX = e.clientX;
            const moveY = e.clientY;
            // 计算鼠标移动的距离
            const disX = moveX - startX;
            const disY = moveY - startY;
            // 根据不同点计算裁剪框的位置和大小 
            let left;
            let top;
            let width;
            let height;
            if (dirDom === doms.leftUp) {
                left = startLeft + disX;
                top = startTop + disY;
                width = startWidth - disX;
                height = startHeight - disY;
            }
            else if (dirDom === doms.up) {
                left = startLeft;
                top = startTop + disY;
                width = startWidth;
                height = startHeight - disY;
            }
            else if (dirDom === doms.rightUp) {
                left = startLeft;
                top = startTop + disY;
                width = startWidth + disX;
                height = startHeight - disY;
            }
            else if (dirDom === doms.right) {
                left = startLeft;
                top = startTop;
                width = startWidth + disX;
                height = startHeight;
            }
            else if (dirDom === doms.rightDown) {
                left = startLeft;
                top = startTop;
                width = startWidth + disX;
                height = startHeight + disY;
            }
            else if (dirDom === doms.down) {
                left = startLeft;
                top = startTop;
                width = startWidth;
                height = startHeight + disY;
            }
            else if (dirDom === doms.leftDown) {
                left = startLeft + disX;
                top = startTop;
                width = startWidth - disX;
                height = startHeight + disY;
            }
            else if (dirDom === doms.left) {
                left = startLeft + disX;
                top = startTop;
                width = startWidth - disX;
                height = startHeight;
            }
            else {
                left = startLeft;
                top = startTop;
                width = startWidth;
                height = startHeight;
            }

            updateCropBoxPosition(left, top, width, height);
        }
        // 鼠标抬起时,停止移动
        document.onmouseup = function () {
            document.onmousemove = null;
            document.onmouseup = null;
        }
    }

})

4.边界检查与尺寸限制

为了确保裁剪框不会超出图片范围,我们实现了边界检查功能:

function updateCropBoxPosition(left, top, width, height) {
    // console.log('left, top, width, height', left, top, width, height);

    // 边界检查
    const imgWidth = doms.img1.offsetWidth;
    const imgHeight = doms.img1.offsetHeight;

    // 设置最小尺寸限制
    const minWidth = 60;
    const minHeight = 60;

    // 确保宽度和高度不小于最小值
    if (width < minWidth) width = minWidth;
    if (height < minHeight) height = minHeight;

    // 防止左侧溢出
    if (left < 0) {
        left = 0;
    }

    // 防止右侧溢出
    if (left + width > imgWidth) {
        // 尝试调整位置保持宽度
        if (width <= imgWidth) {
            left = imgWidth - width;
        } else {
            // 如果宽度大于图片宽度,限制宽度并居中显示
            width = imgWidth;
            left = 0;
        }
    }

    // 防止顶部溢出
    if (top < 0) {
        top = 0;
    }

    // 防止底部溢出
    if (top + height > imgHeight) {
        // 尝试调整位置保持高度
        if (height <= imgHeight) {
            top = imgHeight - height;
        } else {
            // 如果高度大于图片高度,限制高度并居中显示
            height = imgHeight;
            top = 0;
        }
    }

    // 设置裁剪框样式
    doms.main.style.left = `${left}px`;
    doms.main.style.top = `${top}px`;
    doms.main.style.width = `${width}px`;
    doms.main.style.height = `${height}px`;

    // 更新裁剪路径
    doms.img2.style.clipPath = `polygon(
    ${left}px ${top}px,
    ${left + width}px ${top}px,
    ${left + width}px ${top + height}px,
    ${left}px ${top + height}px
    )`;
}

5.最终裁剪结果生成

使用 Canvas API 将裁剪区域导出为新的图片(需要注意使用drawImage时如果img区域与原图大小如果不一致的话,需要额外计算缩放比例来确定最终的裁剪区域):

doms.preview.onclick = function () {
    // 获取裁剪框的位置和大小
    const left = doms.main.offsetLeft;
    const top = doms.main.offsetTop;
    const width = doms.main.offsetWidth;
    const height = doms.main.offsetHeight;
    // console.log(left, top, width, height);
    // 使用canvas将裁剪框中的图片裁剪出来
    const canvas = document.createElement('canvas');
    canvas.width = width;
    canvas.height = height;
    const ctx = canvas.getContext('2d');
    ctx.drawImage(doms.img1, left, top, width, height, 0, 0, width, height);
    // 将裁剪后的图片显示在页面上 不要被裁剪框遮挡
    canvas.style.position = 'absolute';
    canvas.style.left = doms.img1.offsetWidth + 50 + 'px';
    // 如果canvas已经存在,就删除
    if (document.querySelector('canvas')) {
        document.querySelector('canvas').remove();
    }
    document.body.appendChild(canvas);
    canvas.toBlob(function (blob) {
        // console.log(blob);
        const file = new File([blob], 'cut.png', { type: 'image/png' });
        console.log('file', file);
    })
}

总结

通过结合 HTML5 的 File API、CSS 的 clip-path 属性和 Canvas API,我们实现了一个功能完整的纯前端图片裁剪工具。这个实现不需要后端支持,所有操作都在浏览器中完成,具有良好的用户体验和性能表现。
可以根据自己的需求进一步扩展这个工具,例如添加预设裁剪比例、旋转功能、滤镜效果等,使其成为一个更强大的图片编辑工具。