Node.js压缩处理图片并上传至七牛

266 阅读4分钟

图片在上传七牛前,做图片的压缩优化。这样运营在上传图片时,方便将比较大的图片压缩优化,减少H5请求图片的大小,以提高加载速度。

  1. 七牛cdn的前端js sdk在图片上传七牛前,可以设置压缩选项。

可以参考developer.qiniu.com/kodo/1283/j… ,但是因为是基于canvas这个前端浏览器的api,压缩效果一般。

压缩之后的大小有时比压缩前更大。要设置noCompressIfLarger将这样的压缩舍去。所以这样的方式不行。

  1. 用nodejs在中间处理下图片压缩效果比较好。

前端页面

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>图片上传</title>
</head>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/0.21.0/axios.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/clipboard.js/2.0.11/clipboard.min.js"></script>
<body>
  <div class="upload-container">
    <label class="btn">
      <input name="file"
        type="file"
        id="uploadInput"
        accept="image/*"
        required="required">
    </label>
    <button class="btn" style="line-height: 22px;" id="submit" onclick="submitData()">确认上传</button>
  </div>
  <div class="image-container">
    <div><img id="before" src="" alt=""></div>
  </div>
  <div id="resultData" style="display: none;">
    <div>接口响应数据:</div>
    <pre id="result"></pre>
  </div>
  <script>
    let file;
    uploadInput.onchange = function (e) {
      if(e.target.files[0]) {
        file = e.target.files[0];
        getImgUrl('before', window.URL.createObjectURL(this.files[0]))
      }
    }
    async function submitData() {
      if(!file) {
        return
      }
      const res = await upload(file)
      const data = res.data
      result.innerHTML = JSON.stringify(data, null, 2);
      resultData.style.display = 'block';
    }

    function upload(file) {
      const formData = new FormData()
      formData.append('file', file)
      return axios({
        method: 'POST',
        url: '/uploadImg',
        data: formData
      })
    }

    function getImgUrl(id, src) {
      const img = document.getElementById(id)
      img.src = src
    }
  </script>
</body>
</html>

Node.js服务

import fs from 'fs';
import { dirname, join } from 'path';
import FormData from 'form-data';
import axios from 'axios';
import Koa from 'koa';
import Router from 'koa-router';
import multer from 'koa-multer';
import bodyparser from 'koa-bodyparser';
import { fileURLToPath } from 'url';
import tinyImage from './tiny/index.js';
import {
  delFile
} from "./tiny/promiseApi.js";

const __dirname = dirname(fileURLToPath(import.meta.url));

const app = new Koa();
const router = new Router();

app.use(bodyparser());

const getDateStr = () => {
  const d = new Date();
  let month = (d.getMonth()+1);
  month = month > 9 ? month : '0' + month
  let day = d.getDate();
  day = day > 9 ? day : '0' + day
  return d.getFullYear() + '' + month + '' + day
}

const storage = multer.diskStorage({
  // 存储的位置
  destination: join(__dirname, "source"),
  // 文件名
  filename(req, file, cb) {
    cb(null, getDateStr() + '_' + new Date().valueOf() + '_' + file.originalname);
  },
});

const upload = multer({ storage });


//上传图片到七牛
const uploadImgToQiniu = (file, bufferStream, uptoken) => {
    //withCredentials 禁止携带cookie,带cookie在七牛上有可能出现跨域问题
    const axiosInstance = axios.create({withCredentials: false}); 
    let data = new FormData();
    let type = file.filename.split('.');
    data.append('token', uptoken); 
    // 如果有七牛的accessKey和secretKey,则获取token这个请求可以省略。accessKey和secretKey可以存储在nodejs,没有暴露在前端的风险。
    data.append('file', bufferStream);
    data.append('key', 'image/' + getDateStr() + '/' + new Date().valueOf() + '.' + type[type.length-1]);
    return axiosInstance({
        headers: {
          'Content-Type': 'multipart/form-data'
        },
        method: 'POST',
        url: 'https://xxxx.qiniup.com/',
        data: data,
        timeout: 60000,
        onUploadProgress: (progressEvent)=> {
            let imgLoadPercent = Math.round(progressEvent.loaded * 100 / progressEvent.total);
            console.log(file.originalname + '上传进度:' + imgLoadPercent)
        },
    })
}

router.post("/uploadImg", upload.single("file"), async (ctx) => {
    let file = ctx.req.file;
    let filepath = '';
    try {
      const imageRes = await tinyImage(file.filename);
      filepath = imageRes.filepath;
    } catch (error) {
      console.error('压缩 error: ', error)
      console.log('压缩图片出错,跳过压缩');
    }
    let uptoken = '';
    let result = {};
    try {
      let bufferStream = fs.createReadStream(filepath)
      result = await uploadImgToQiniu(file, bufferStream, uptoken);
    } catch (error) {
      console.error('qiniu error: ', error)
      ctx.body = JSON.stringify({
        msg: '上传图片到七牛出错',
        code: 101
      });
      return;
    } finally {
      // 上传完后,删除压缩后的图片
      await delFile(filepath);
    }
   
    ctx.body = JSON.stringify(result.data)
});

app.use(router.routes()).use(router.allowedMethods());

app.use((ctx) => {
  ctx.set("Content-Type", "text/html");
  ctx.body = fs.readFileSync(join(__dirname, "index.html"));
});

app.listen(3333, () => {
  console.log("服务启动: http://localhost:3333");
});

node层压缩逻辑的实现, ./tiny/index.js

import imagemin from 'imagemin';
import path, { dirname } from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import {getImageminPlugins} from './plugins.js';
import {
  delFile,
  uploadFile,
  updateFile,
  writeFile
} from "./promiseApi.js";

const __dirname = dirname(fileURLToPath(import.meta.url));

const options = {
    skipLargerFile: true,
    gifsicle: {
      optimizationLevel: 7,
      interlaced: false,
    },
    optipng: {
      optimizationLevel: 7,
    },
    mozjpeg: {
      quality: 75,
      progressive: true,
    },
    pngquant: {
      quality: [0.8, 0.9],
      speed: 4,
    },
    svgo: {
      plugins: [
        {
          name: 'removeViewBox',
        },
        {
          name: 'removeEmptyAttrs',
          active: false,
        },
      ],
    }
};

// 先本地用ImageMin压缩
export async function localImageMinFile(filePath, buffer) {
    let content
    try {
      content = await imagemin.buffer(buffer, {
        plugins: getImageminPlugins(options),
      })

      let size = content.byteLength
      const oldSize = buffer.byteLength

      if (options.skipLargerFile && size > oldSize) {
        content = buffer
        size = oldSize
      }
      
      return content;
    } catch (error) {
      console.error('imagemin error:' + error)
    }
}


export default async function tinyImage (picPath) {
  let filepath = path.join(__dirname, '../source', picPath);
  let pathStr = filepath;
  const source = fs.readFileSync(filepath);
  let imageminFilepath = '';
  let imageIsErr = false;
  try {
    const content = await localImageMinFile(filepath, source)
    if(content) {
      imageminFilepath = path.join(__dirname, '../source', 'imagemin_' + picPath);
      fs.writeFileSync(imageminFilepath, content);
      // 写完后,删除原图片
      await delFile(filepath);
      pathStr = imageminFilepath;
    }
  } catch (error) {
    imageIsErr = true;
    console.log('写入ImageMin压缩后的文件失败:', error)
  }
  return { filepath: pathStr };
}

plugins.js逻辑

import imageminGif from "imagemin-gifsicle";
import imageminPng from "imagemin-pngquant";
import imageminOptPng from "imagemin-optipng";
import imageminJpeg from "imagemin-mozjpeg";
import imageminSvgo from "imagemin-svgo";
import imageminWebp from "imagemin-webp";
import imageminJpegTran from "imagemin-jpegtran";

export const isBoolean = (arg) => {
  return typeof arg === "boolean";
};

export const isNotFalse = (arg) => {
  return !(isBoolean(arg) && !arg);
};

export const isRegExp = (arg) =>
  Object.prototype.toString.call(arg) === '[object RegExp]'

export const isFunction = (arg) =>
  typeof arg === 'function'

export function getImageminPlugins(options) {
  const {
    gifsicle = true,
    webp = false,
    mozjpeg = false,
    pngquant = false,
    optipng = true,
    svgo = true,
    jpegTran = true,
  } = options;

  const plugins = [];

  if (isNotFalse(gifsicle)) {
    const opt = isBoolean(gifsicle) ? undefined : gifsicle;
    plugins.push(imageminGif(opt));
  }

  if (isNotFalse(mozjpeg)) {
    const opt = isBoolean(mozjpeg) ? undefined : mozjpeg;
    plugins.push(imageminJpeg(opt));
  }

  if (isNotFalse(pngquant)) {
    const opt = isBoolean(pngquant) ? undefined : pngquant;
    plugins.push(imageminPng(opt));
  }

  if (isNotFalse(optipng)) {
    const opt = isBoolean(optipng) ? undefined : optipng;
    plugins.push(imageminOptPng(opt));
  }

  if (isNotFalse(svgo)) {
    const opt = isBoolean(svgo) ? undefined : svgo;
    plugins.push(imageminSvgo(opt));
  }

  if (isNotFalse(webp)) {
    const opt = isBoolean(webp) ? undefined : webp;
    plugins.push(imageminWebp(opt));
  }

  if (isNotFalse(jpegTran)) {
    const opt = isBoolean(jpegTran) ? undefined : jpegTran;
    plugins.push(imageminJpegTran(opt));
  }
  return plugins;
}

在nodejs里面往类似七牛cdn服务器上传图片,如果不用七牛的npm包,需要构造form表单提交。

import fs from 'fs';

// node读取本地文件
const f = fs.readFileSync(filepath);
const bolb = new Blob([f]);
// 构造form提交
let data = new FormData();
data.append('file', bolb);
axios({
    headers: {
      'Content-Type': 'multipart/form-data'
    },
    method: 'POST',
    url: 'xxxx',
    data: data,
    timeout: 60000,
    onUploadProgress: (progressEvent)=> {},
})

注意点: 发送带数据的请求时,数据可以是以下类型:

  • string
  • object
  • ArrayBuffer
  • ArrayBufferView
  • URLSearchParams
  • Form Data
  • File
  • Blob
  • Stream
  • Buffer

注意: Stream 和 Buffer 仅适用于 Node,而 Form Data、File 和 Blob 仅适用于浏览器

Nodejs也没有FormData这个Web API,要使用'form-data' npm包。

import fs from 'fs';
import FormData from 'form-data';

// node读取本地文件
let bufferStream = fs.createReadStream(filepath) 
// 构造form提交
let data = new FormData();
data.append('file', bufferStream);
axios({
    headers: {
      'Content-Type': 'multipart/form-data'
    },
    method: 'POST',
    url: 'xxxx',
    data: data,
    timeout: 60000,
    onUploadProgress: (progressEvent)=> {},
})