笔者在《使用eggjs+typescript+react开发全栈项目(博客CMS)》提到了图片上传方案。
这个方案上线之后遇到了一个问题:上传到相册内都是用单反拍摄的高清大图,每张图片体积都在1MB以上,导致在加载图片列表时,耗时非常长,严重影响用户体验。
思索之后,想到的是解决方案是生成一张缩略图,加载图片列表时,显示缩略图;查看具体某张图片时,显示原图。
前期准备
既然要处理图片,就需要一个图片处理库。业界知名的处理库有两个:ImageMagick
和GraphicsMagick
,两个库功能基本一致。初步了解了一下,GraphicsMagick
比ImageMagick
速度更快,更稳定。因此,我选择GraphicsMagick
做为图片处理库。
MacOS安装GraphicsMagick
MacOS用户是最幸福的,只需一行命令就行完成安装。
$ brew install graphicsmagick
CentOS安装GraphicsMagick
网站服务器是CentOS系统,这个安装就麻烦多了。
首先要安装依赖。
$ yum install -y gcc libpng libjpeg libpng-devel libjpeg-devel ghostscript libtiff libtiff-devel freetype freetype-devel
下载并解压,官方的源无法连接,所以我换了一个源地址。
$ wget http://78.108.103.11/MIRROR/GraphicsMagick/1.3/GraphicsMagick-1.3.29.tar.gz
$ tar -zxvf GraphicsMagick-1.3.29.tar.gz
编辑安装
$ cd GraphicsMagick-1.3.29
$ ./configure --prefix=/root/GraphicsMagick-1.3.29
$ make
$ make install
设置环境变量
$ vi /etc/profile
## 添加以下代码
export GMAGICK_HOME=/root/GraphicsMagick-1.3.29
export PATH=$GMAGICK_HOME/bin:$PATH
$ source /etc/profile
检查安装是否成功
$ gm version
项目安装gm模块
库安装完之后,我们还需要让nodejs能够使用GraphicsMagick
。
gm
模块可以让用户在nodejs环境操作GraphicsMagick
库。我们通过npm
命令安装到项目中即可。
$ npm install --save gm
图片处理
考虑我们的图片列表都是以1:1的比例展示图片的,所以裁切方案是:先把图片裁成1:1宽高比,然后缩小成宽高均为1000px的图片。
计算裁切范围
由于原图的宽高比各种各样,因此我们需要对不同类型的图片进行分类处理。
代码层面上,我们通过gm
的size()
方法获取图片宽高,计算出裁切需要的width
、height
、x
、y
四个值。为保持代码风格一致,将这个过程Promise化了。
/**
* 图片裁切范围
*
* @param size 图片尺寸
*/
_getCropArea(file: IFile): Promise<ICropArea> {
return new Promise((resolve, reject) => {
gm(file.filepath).size((err, size) => {
if (!err) {
if (size.width >= size.height) {
resolve({
width: size.height,
height: size.height,
x: (size.width - size.height) / 2,
y: 0,
})
} else {
resolve({
width: size.width,
height: size.width,
x: 0,
y: (size.height - size.width) / 2,
})
}
} else {
reject(err);
}
})
})
}
裁切图片,获取缩略图
获取裁切区域之后,利用crop()
对图片进行裁切,然后通过resize()
缩小至1000px,输出到指定路径。
/**
* 裁切图片,返回缩略图
*
* @param file
*/
async _cropImg(file: IFile, targetPath: string, targetName: string): Promise<string> {
const area: ICropArea = await this._getCropArea(file);
return new Promise((resolve, reject) => {
const filename: string = `${targetName}_${PREVIEW_IMG_SIZE.width}x${PREVIEW_IMG_SIZE.height}.${file.filename.split(".").slice(-1)}`;
const filePath = path.join(targetPath, filename);
gm(file.filepath)
.crop(area.width, area.height, area.x, area.y)
.resize(PREVIEW_IMG_SIZE.width, PREVIEW_IMG_SIZE.height)
.write(filePath, (err) => {
if (!err) {
resolve(filename);
} else {
reject(err);
}
})
})
}
最后来看,改造后的路由方法。
async upload() {
const { ctx, app } = this;
if (!ctx.isAuthenticated()) throw { status: 401 };
try {
const file = ctx.request.files[0];
const needCrop = parseInt(ctx.request.body.crop) || 0;
if (!file) throw { status: 404, message: 'file not found' };
// 确认图片文件夹
const targetPath = path.join(app.baseDir, app.config.uploadPath, moment().format("YYYYMM"));
await fs.ensureDir(targetPath);
// 保存完整图片
const filename = `pic${new Date().getTime()}`;
const filepath = `${filename}.${file.filename.split(".").slice(-1)}`;
await fs.copyFile(file.filepath, path.join(targetPath, filepath));
let outputPath = "/assets/upload/" + moment().format("YYYYMM") + "/" + filepath;
// 生成缩略图
if (needCrop) {
const previewPath = await this._cropImg(file, targetPath, filename);
outputPath = "/assets/upload/" + moment().format("YYYYMM") + "/" + previewPath;
}
ctx.body = {
code: 0,
message: "success",
url: outputPath
};
} catch (err) {
throw { status: 500, message: err }
} finally {
await ctx.cleanupRequestFiles();
}
}
根据参数crop
判断是否需要裁切。需要裁切时,原图和缩略图的文件名除了最后的后缀以外保持一致,然后返回缩略图地址,需要展示原图时,只要将后缀(_1000x1000)去掉即可。