前言
最近公司的后台项目中,遇到个需求:如果客户上传的是 A3 样式的图片,前端需将该图片从中间切割,分为左右对等的两半,即 2 张 A4 样式的图片后,再传给服务器。一开始我去网上找了一些截图的库,比如 cropperjs 来实现该功能,后来学习了截图背后的原理,发现针对公司的具体需求,根本用不着安装第三方库,自己写一个纯函数即可实现,本文就来说说具体过程。
实现图片的一分为二
根据图片尺寸生成 canvas 画布
函数接收的参数为 File 类型的图片文件 imgFile,它可以通过 <input type="file"> 或是第三方 UI 库的上传组件获取。拿到了 imgFile 后,就可以传给 URL.createObjectURL() 获取图片的 URL 的字符串 imgObjUrl,然后生成图片 img:
typescript
// 获取图片文件名、类型备用
const imgName = imgFile.name
const imgType = imgFile.type
imgObjUrl = URL.createObjectURL(imgFile)
img = new Image()
img.src = imgObjUrl
注意,通过 URL.createObjectURL() 创建的 URL 的生命周期会与其创建时所在窗口的 document 绑定在一起,所以为了更好的性能和内存使用状况,需要在合适的地方调用 URL.revokeObjectURL() 释放 URL。
等图片加载完成 ,就可以创建 canvas 元素,让它的宽高属性值等于图片的固有的宽高。注意,设置 canvas 宽高的时候不能仅通过 canvas.style.width = img.naturalWidth + 'px' 或 canvas.style.height = img.naturalHeight + 'px',这样设置的是样式尺寸,在不同的 devicePixelRatio 下会表现不一致:
typescript
img.onload = () => {
canvas = document.createElement('canvas')
ctx = canvas.getContext('2d', { willReadFrequently: true })
canvas.width = width = img.naturalWidth
canvas.height = height = img.naturalHeight
}
因为之后会多次使用到 ctx.getImageData() 方法,所以给 canvas.getContext() 传入第二个参数 { willReadFrequently: true }, 迫使浏览器使用 2D canvas 并节省内存(而不是硬件加速)。否则可能会遇到如下警告:

绘制完整图片到 canvas 画布
有了图片,有了 canvas 画布,就可以将图片绘制到画布上去了。绘制前调用 ctx.clearRect() 清空画布,其中 width 和 height 等于图片的固有宽高,也等于画布的宽高:
typescript
ctx.clearRect(0, 0, width, height)
ctx.drawImage(img, 0, 0, width, height)
获取 canvas 画布半边的像素数据
接着就可以通过 ctx.getImageData() 方法获取 canvas 画布的左右两边的隐含像素数据了,传入的参数分别代表要提取数据的矩形区域的左上角 x,y 坐标和宽高。比如我们要提取左半边的数据:
typescript
const imgData = ctx.getImageData(0, 0, width / 2, height)
如果上传一张纯红色的图片,打印 imgData 获取到的结果如下,data 属性中就是各个像素点的颜色信息 ------ 255, 0, 0 即为纯红色 :
如果你想对图片进行置灰、反相之类的颜色处理,可以通过改变 data 中的数据实现。
根据像素数据生成新的 canvas
创建一个新的 canvas 画布,该画布的宽度为完整图片的一半,高度保持一致,然后把得到的半边图片的像素数据通过 ctx2.putImageData() 从新画布的 (0, 0) 点开始绘制到新画布上:
typescript
const screenshotCanvas = document.createElement('canvas')
const screenshotCtx = screenshotCanvas.getContext('2d')
screenshotCanvas.width = width / 2
screenshotCanvas.height = height
screenshotCtx.putImageData(imgData, 0, 0)
canvas 生成 File
canvas 画布生成 File 对象的过程是先由 canvas 的 toBlob() 方法让画布上的图片生成 Blob 类型的对象 blob,再把 blob 传给 new File() 生成 File 对象,第 2、3 个参数分别指定文件的名称和类型:
typescript
screenshotCanvas.toBlob((blob) => {
if (!blob) return
const file = new File([blob], `${position}-${imgName}`, { type: imgType })
files.push(file)
}, imgType)
打印查看 file 对象,可以看到它的原型对象 File 的原型对象,其实就是 Blob:

现在,当用户上传 A3 样式的图片时,实际传给服务器的文件数据,就是切割图片后生成的文件对象数组 files 了。
打包发布到 npm
之前在 《npm 细节与原理探究,顺便发布个自己的包》里介绍过如何将一个用 js 编写的项目打包发布到 npm 的仓库,本项目中的代码是使用 ts 编写的,应该如何打包呢?其实就是多了个将 ts 文件编译为 js 文件的过程,其中涉及到以下 2 个文件的修改或创建:
package.json
在 package.json 中,项目的主入口文件 "main" 的值需要是个 js 文件,所以需要安装 typescript 以便使用 tsc 编译 ts 文件;
"types":值为 ts 类型声明文件,tsc 编译时生成;
"scripts":添加打包命令 build,执行时就会编译 tsconfig.json 中 include 指定的 ts 文件,prepublishOnly 则是在发布到 npm 仓库前执行的命令:
json
{
"name": "bisect-image-to-files",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "npx tsc",
"prepublishOnly": "pnpm build"
}
}
tsconfig.json
执行 tsc --init 可以生成 tsconfig.json:
json
{
"compilerOptions": {
"target": "es2015",
"module": "ES2015",
"rootDir": "./src",
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
compilerOptions 对象中:
target:指定编译生成的 js 文件的 ECMAScript 版本;module:指定模块化方案;rootDir:指定源文件的目录,也就是我们编写的 ts 文件的目录;declaration:为true则会在编译时生成 .d.ts 文件;sourceMap:为true则是在编译后生成 .map 文件,方便问题追踪;outDir:指定编译后生成的文件的存放目录。
include:指定哪些目录下的 ts 文件需要编译;
exclude:指定哪些文件不用被编译。
其它步骤则和发布 js 编写的项目一样:登录 npm 仓库,然后通过 npm publish 发布即可。发布成功后就能在 npm 仓库中搜到本项目了:

效果演示
在使用了 element-plus 的框架的 vue3 后台项目中,安装我们发布到 npm 的包:
powershell
pnpm add bisect-image-to-files
在需要上传图片的组件内就可以直接引入使用了:
vue
<script lang="tsx" setup>
import bisectImageToFiles from 'bisect-image-to-files'
const [左半边图片文件, 右半边图片文件] = await bisectImageToFiles(图片文件)
</script>
我用 express 写了个服务器接收上传的图片,效果如下,在前端点击上传图片后,就能在服务器项目中的静态目录 uploads 中查看到左右两张图片了:

