Nodejs实现文件上传

Form上传

1、前端

<section>
  <h2>上传单个文件</h2>
  <form action="/api/upload/single" method="post" enctype="multipart/form-data">
    <input type="file" name="file" accept="image/*" required="true" />
    <button>上传</button>
  </form>
</section>

2、后端

var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');
const fs = require('fs-extra');
const multer = require('multer');

var app = express();
const port = 3000;

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');

app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

const uploadDir = path.join(__dirname, './public/uploads');

const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, uploadDir);
  },
  filename: function (req, file, cb) {
    fs.access(path.resolve(uploadDir, file.originalname), err => {
      if (err) {
        cb(null, file.originalname);
      } else {
        const name = path.parse(file.originalname).name;
        const ext = path.parse(file.originalname).ext;
        const timestamp = Date.now();
        const filename = name + '-' + timestamp + '' + ext;
        cb(null, filename);
      }
    });
  },
});

const upload = multer({
  storage: storage,
  fileFilter: function (req, file, cb) {
    file.originalname = file.originalname.toLowerCase();
    if (!file.originalname.match(/\.(png|jpg|jpeg)$/)) {
      cb(new Error('只能上传png/jpg/jpeg格式图片'), false);
    }
    cb(null, true);
  },
});

// 上传单个文件
app.post('/api/upload/single', upload.single('file'), (req, res) => {
  res.json(req.file);
});

// catch 404 and forward to error handler
app.use(function (req, res, next) {
  next(createError(404));
});

// error handler
app.use(function (err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error');
});

app.listen(port, () => {
  console.log(`app is listening at http://localhost:${port}`);
});

Form上传多个文件

1、前端

input[type=‘file’]设置multiple属性

<section>
  <h2>上传多个文件</h2>
  <form action="/api/upload/more" method="post" enctype="multipart/form-data">
    <input type="file" name="files" accept="image/*" multiple required="true" />
    <button>上传</button>
  </form>
</section>

2、后端

// 上传多个文件
app.post('/api/upload/more', upload.array('files'), (req, res) => {
  res.json(req.files);
});

Ajax上传

1、前端html

<section>
  <h2>Ajax上传文件</h2>
  <input type="file" name="files" accept="image/*" multiple style="display: none" id="ajaxInput" />
  <button id="ajaxBtn">上传</button>
  <progress id="ajaxProgress" max="100" value="0">0%</progress>
</section>

2、前端JS

//上传文件
const uploadFiles = (files, progressBar) => {
  if (files.length <= 0) {
    alert('请至少选择一个文件');
    return;
  }

  var fd = new FormData();
  for (let i = 0; i < files.length; i++) {
    fd.append('files', files[i]);
  }
  var xhr = new XMLHttpRequest();
  xhr.onload = function (e) {
    if (xhr.status === 200) {
      var response = JSON.parse(xhr.responseText);
      console.log(response);
    }
  };
  xhr.upload.addEventListener('progress', function (e) {
    if (e.lengthComputable) {
      var precent = Math.floor((e.loaded / e.total) * 100);
      if(progressBar) {
      	progressBar.value = precent;
      	progressBar.innerHTML = precent + '%';
      }
    } else {
      console.log('unable to compute progress information');
    }
  });
  xhr.open('POST', '/api/upload/more');
  xhr.send(fd);
}

//ajax上传
const ajaxUpload = function () {
  const ajaxInput = document.getElementById('ajaxInput');
  const ajaxBtn = document.getElementById('ajaxBtn');
  const ajaxProgress = document.getElementById('ajaxProgress');

  ajaxBtn.addEventListener(
    'click',
    function () {
      ajaxInput.click();
    },
    false,
  );

  ajaxInput.addEventListener(
    'change',
    function () {
      const files = this.files;
      uploadFiles(files, ajaxProgress);
    },
    false,
  );
};
ajaxUpload();

拖拽上传

1、前端Html

<section>
  <h2>拖拽上传文件</h2>
  <div
    id="dropzone"
    class="dropzone"
    ondrop="dragUpload.drop(event)"
    ondragover="dragUpload.dragover(event)"
  >
    <span>Drop files here</span>
  </div>
  <progress id="dragProgress" max="100" value="0">0%</progress>
</section>

2、前端JS

const dragUpload = {
  progressBar: document.getElementById('dragProgress'),
  drop: function (ev) {
    ev.stopPropagation();
    ev.preventDefault();
    var data = ev.dataTransfer;
    var files = data.files;

    uploadFiles(files, this.progressBar);
  },
  dragover: function (ev) {
    ev.preventDefault();
    ev.dataTransfer.dropEffect = 'move';
  },
};

大文件上传

1、前端Html

<section>
  <h2>大上传文件</h2>
  <input type="file" name="file" required="true" id="fileInput" />
  <button id="fileUpload">上传</button>
</section>

2、前端JS

//大文件上传
const bigFileUpload = function () {
  const fileInput = document.querySelector('#fileInput');
  const fileUpload = document.querySelector('#fileUpload');

  const CHUNK_SIZE = 2 * 1024 * 1024; //设置切片大小2MB

  //文件切片
  const slice = (file, piece = 1024 * 1024 * 5) => {
    const totalSize = file.size; // 文件总大小
    let start = 0; // 每次上传的开始字节
    let end = start + piece; // 每次上传的结尾字节
    const chunks = [];
    while (start < totalSize) {
      const blob = file.slice(start, end);
      chunks.push(blob);

      start = end;
      end = start + piece;
    }
    return chunks;
  };

  const obj2str = obj => {
    let w = Object.entries(obj);
    w.forEach((v, i) => {
      w[i] = v.join('=');
    });
    return w.join('&');
  };

  //上传切片
  const uploadChunk = formData => {
    return new Promise((resolve, reject) => {
      var xhr = new XMLHttpRequest();
      xhr.onload = function (e) {
        if (xhr.status === 200) {
          resolve(xhr.responseText);
        } else {
          reject('error');
        }
      };
      xhr.onerror = function (e) {
        reject(e);
      };
      xhr.open('POST', '/api/chunk/upload');
      xhr.send(formData);
    });
  };

  //合并切片
  const mergeChunk = data => {
    return new Promise((resolve, reject) => {
      var xhr = new XMLHttpRequest();

      xhr.onload = function (e) {
        if (xhr.status === 200) {
          resolve(xhr.responseText);
        } else {
          reject('error');
        }
      };
      xhr.onerror = function (e) {
        reject(e);
      };
      xhr.open('POST', '/api/chunk/merge');
      xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
      xhr.send(obj2str(data));
    });
  };

  // 创建上传切片任务数组
  const createTasks = (file, chunks) => {
    let tasks = [];
    chunks.forEach((chunk, index) => {
      let fd = new FormData();
      fd.append('file', chunk);
      fd.append('index', index + 1);
      fd.append('name', file.name);
      fd.append('size', file.size);
      fd.append('total', chunks.length);
      fd.append('hash', file.name + file.size); //以文件名+大小作为文件唯一标识符
      tasks.push(uploadChunk(fd));
    });
    return tasks;
  };

  fileUpload.addEventListener(
    'click',
    function () {
      const file = fileInput.files[0];
      const chunks = slice(file, CHUNK_SIZE);
      const tasks = createTasks(file, chunks);
      Promise.all(tasks).then(res => {
        console.log(res);
        const data = {
          name: file.name,
          size: file.size,
          total: chunks.length,
          hash: file.name + file.size,
        };
        mergeChunk(data).then(res => console.log(res));
      });
    },
    false,
  );
};
bigFileUpload();

3、后端

const uploadChunk = multer({ dest: uploadDir }).single('file');

// 上传文件切片
app.post('/api/chunk/upload', (req, res) => {
  uploadChunk(req, res, function (err) {
    if (err) {
      return;
    }
    const { name, total, index, size, hash } = req.body;
    //切片保存的目录(以hash值作为唯一目录名)
    const chunksPath = path.join(uploadDir, hash, '/');
    if (!fs.existsSync(chunksPath)) {
      fs.mkdirSync(chunksPath);
    }
    //重命名切片名称(添加切片序号)
    fs.renameSync(req.file.path, chunksPath + hash + '-' + index);
    res.status = 200;
    res.end('1');
  });
});

// 合并文件切片
app.post('/api/chunk/merge', (req, res) => {
  let { size, name, total, hash } = req.body;
  total = +total;
  const chunksPath = path.join(uploadDir, hash, '/');
  const filePath = path.join(uploadDir, name);
  const chunks = fs.readdirSync(chunksPath);
  // 创建存储文件
  fs.writeFileSync(filePath, '');
  if (chunks.length !== total || chunks.length === 0) {
    res.status = 200;
    res.end('chunk number error');
    return;
  }
  for (let i = 1; i <= total; i++) {
    //追加切片内容
    fs.appendFileSync(filePath, fs.readFileSync(chunksPath + hash + '-' + i));
    //删除切片文件
    fs.unlinkSync(chunksPath + hash + '-' + i);
  }
  //删除切片保存目录
  fs.rmdirSync(chunksPath);
  res.status = 200;
  res.end('success');
});

完整代码

server.js
https://raw.githubusercontent.com/pengjielee/nodeapps/main/examples/upload/server.js

index.html
https://raw.githubusercontent.com/pengjielee/nodeapps/main/examples/upload/public/index.html

注意

1、FormData添加多个文件时,不能直接把files添加进去。

// 错误的
const files = fileInput.files;
var fd = new FormData();
fd.append('files', files);

// 正确的
const files = fileInput.files;
var fd = new FormData();
for (let i = 0; i < files.length; i++) {
  fd.append('files', files[i]);
}

2、XMLHttpRequest.setRequestHeader() 必须在 open() 之后、send() 之前调用 setRequestHeader() 方法。