File API 与文件操作

深入理解 File API 的完整体系:File、Blob、FileReader、URL.createObjectURL,以及文件上传、拖放、缩略图生成等实际应用。

File API 与文件操作(File API and File Operations)

一、File API 概述

1.1 File API 的组成

File API 是浏览器提供的一套用于处理文件的 API,主要包括:

  • File:表示用户选择的文件
  • Blob:表示原始二进制数据
  • FileReader:异步读取文件内容
  • URL.createObjectURL():创建文件 URL
  • FormData:构建multipart/form-data 数据

这些 API 使得 Web 应用可以像桌面应用一样处理文件,而不需要服务器参与。

1.2 文件选择

<input type="file" id="fileInput">
<input type="file" id="multipleFiles" multiple>
<input type="file" id="imageFiles" accept="image/*">
<input type="file" id="videoFiles" accept="video/*">
<input type="file" id="audioFiles" accept="audio/*">
<input type="file" id="customTypes" accept=".pdf,.doc,.docx">
const fileInput = document.getElementById('fileInput');

fileInput.addEventListener('change', (e) => {
  const files = e.target.files;
  console.log('Files count:', files.length);

  for (const file of files) {
    console.log('Name:', file.name);
    console.log('Size:', file.size);
    console.log('Type:', file.type);
    console.log('Last Modified:', new Date(file.lastModified));
  }
});

二、File 和 Blob

2.1 File 对象的属性

const fileInput = document.getElementById('fileInput');

fileInput.addEventListener('change', (e) => {
  const file = e.target.files[0];

  // 核心属性
  console.log(file.name);         // 文件名
  console.log(file.size);         // 文件大小(字节)
  console.log(file.type);         // MIME 类型
  console.log(file.lastModified);   // 上次修改时间(时间戳)
  console.log(file.lastModifiedDate); // 上次修改时间(Date 对象)
  console.log(file.webkitRelativePath); // 相对路径(某些浏览器)
});

2.2 Blob 对象

Blob(Binary Large Object)表示原始二进制数据,File 继承自 Blob:

// 创建 Blob
const blob = new Blob(['Hello, world!'], { type: 'text/plain' });

// 从 Blob 获取信息
console.log(blob.size);  // 13
console.log(blob.type);  // "text/plain"

// 切片 Blob
const partial = blob.slice(0, 5);  // 前 5 个字节

// 转换为其他格式
const url = URL.createObjectURL(blob);

// 转换为 text
const textPromise = blob.text();
const text = await textPromise;

// 转换为 ArrayBuffer
const arrayBufferPromise = blob.arrayBuffer();
const arrayBuffer = await arrayBufferPromise;

2.3 File 继承关系

Blob
 └── File
      ├── FileList (非标准)
      └── DataTransferItem (拖放 API)

2.4 文件类型检测

// 检测 MIME 类型
function isImage(file) {
  return file.type.startsWith('image/');
}

function isPDF(file) {
  return file.type === 'application/pdf';
}

function isVideo(file) {
  return file.type.startsWith('video/');
}

// 根据扩展名检测
function getExtension(filename) {
  return filename.slice((filename.lastIndexOf('.') - 1 >>> 0) + 2).toLowerCase();
}

function isTextFile(file) {
  const textTypes = ['text/plain', 'text/html', 'text/css', 'text/javascript'];
  return textTypes.includes(file.type) ||
         ['.txt', '.md', '.json', '.xml', '.csv'].some(ext =>
           file.name.toLowerCase().endsWith(ext)
         );
}

三、FileReader API

3.1 FileReader 的方法

const reader = new FileReader();

// 读取为文本
reader.readAsText(file);

// 读取为 Data URL(base64)
reader.readAsDataURL(file);

// 读取为 ArrayBuffer
reader.readAsArrayBuffer(file);

// 中止读取
reader.abort();

3.2 FileReader 事件

const reader = new FileReader();

reader.onload = (e) => {
  console.log('Load complete');
  console.log('Result:', reader.result);
};

reader.onerror = (e) => {
  console.error('Error:', reader.error);
};

reader.onprogress = (e) => {
  if (e.lengthComputable) {
    const percent = (e.loaded / e.total) * 100;
    console.log(`Progress: ${percent.toFixed(2)}%`);
  }
};

reader.onloadstart = () => console.log('Load started');
reader.onloadend = () => console.log('Load ended');
reader.onabort = () => console.log('Load aborted');

3.3 读取示例

// 读取文本文件
async function readTextFile(file) {
  const reader = new FileReader();
  return new Promise((resolve, reject) => {
    reader.onload = () => resolve(reader.result);
    reader.onerror = () => reject(reader.error);
    reader.readAsText(file);
  });
}

// 读取图片并显示
async function readImageAsDataURL(file) {
  const reader = new FileReader();
  return new Promise((resolve, reject) => {
    reader.onload = () => resolve(reader.result);
    reader.onerror = () => reject(reader.error);
    reader.readAsDataURL(file);
  });
}

// 使用
const fileInput = document.getElementById('imageInput');
fileInput.addEventListener('change', async (e) => {
  const file = e.target.files[0];
  if (file && file.type.startsWith('image/')) {
    const dataUrl = await readImageAsDataURL(file);
    document.getElementById('preview').src = dataUrl;
  }
});

四、URL.createObjectURL

4.1 基本用法

// 创建对象 URL
const url = URL.createObjectURL(file);

// 使用 URL
const img = document.createElement('img');
img.src = url;
document.body.appendChild(img);

// 释放 URL
URL.revokeObjectURL(url);

4.2 与 FileReader 的对比

特性 createObjectURL FileReader
性能 更快(不复制数据) 较慢(复制到内存)
内存 由浏览器管理 JavaScript 堆
生命周期 document 存在期间有效 可持久使用
适用场景 临时预览、video/audio src base64 编码、上传到服务器

4.3 完整示例:图片预览

class ImagePreview {
  constructor(inputId, previewId) {
    this.input = document.getElementById(inputId);
    this.preview = document.getElementById(previewId);
    this.currentUrl = null;

    this.input.addEventListener('change', () => this.handleChange());
  }

  handleChange() {
    const file = this.input.files[0];
    if (!file) return;

    // 释放旧的 URL
    if (this.currentUrl) {
      URL.revokeObjectURL(this.currentUrl);
    }

    // 创建新的 URL
    this.currentUrl = URL.createObjectURL(file);
    this.preview.src = this.currentUrl;
  }

  destroy() {
    if (this.currentUrl) {
      URL.revokeObjectURL(this.currentUrl);
    }
  }
}

// 使用
const preview = new ImagePreview('fileInput', 'preview');

五、拖放文件

5.1 拖放区域

<div id="dropZone" class="drop-zone">
  <p>拖放文件到这里</p>
</div>
const dropZone = document.getElementById('dropZone');

// 阻止默认行为
dropZone.addEventListener('dragover', (e) => {
  e.preventDefault();
  e.stopPropagation();
});

dropZone.addEventListener('dragenter', (e) => {
  e.preventDefault();
  e.stopPropagation();
  dropZone.classList.add('drag-over');
});

dropZone.addEventListener('dragleave', (e) => {
  e.preventDefault();
  e.stopPropagation();
  dropZone.classList.remove('drag-over');
});

dropZone.addEventListener('drop', (e) => {
  e.preventDefault();
  e.stopPropagation();
  dropZone.classList.remove('drag-over');

  const files = e.dataTransfer.files;
  console.log('Dropped files:', files);
});

5.2 拖放与文件列表

// 处理拖放的文件
dropZone.addEventListener('drop', (e) => {
  const files = e.dataTransfer.files;

  for (const file of files) {
    // 检查文件类型
    if (!file.type.startsWith('image/')) {
      console.warn('Not an image:', file.name);
      continue;
    }

    // 预览
    const url = URL.createObjectURL(file);
    console.log('Preview URL:', url);
  }
});

// 获取拖放的文本内容
dropZone.addEventListener('drop', (e) => {
  const text = e.dataTransfer.getData('text/plain');
  console.log('Dropped text:', text);
});

// 多类型拖放
dropZone.addEventListener('drop', (e) => {
  // 文件
  if (e.dataTransfer.files.length > 0) {
    console.log('Files:', e.dataTransfer.files);
  }

  // 纯文本
  if (e.dataTransfer.types.includes('text/plain')) {
    console.log('Plain text:', e.dataTransfer.getData('text/plain'));
  }

  // HTML
  if (e.dataTransfer.types.includes('text/html')) {
    console.log('HTML:', e.dataTransfer.getData('text/html'));
  }
});

六、大文件处理

6.1 分片读取

class ChunkedFileReader {
  constructor(file, chunkSize = 1024 * 1024) {
    this.file = file;
    this.chunkSize = chunkSize;
    this.offset = 0;
  }

  async readNextChunk() {
    if (this.offset >= this.file.size) {
      return null;
    }

    const slice = this.file.slice(this.offset, this.offset + this.chunkSize);
    const buffer = await slice.arrayBuffer();

    this.offset += chunkSize;

    return {
      data: buffer,
      offset: this.offset,
      progress: this.offset / this.file.size
    };
  }

  async readAllChunks(onProgress) {
    const chunks = [];
    let result;

    while ((result = await this.readNextChunk()) !== null) {
      chunks.push(result.data);
      if (onProgress) {
        onProgress(result.progress);
      }
    }

    return new Blob(chunks);
  }
}

// 使用
const reader = new ChunkedFileReader(file, 2 * 1024 * 1024); // 2MB chunks

reader.readAllChunks((progress) => {
  console.log(`Progress: ${(progress * 100).toFixed(2)}%`);
}).then((blob) => {
  console.log('All chunks read:', blob);
});

6.2 大文件上传

async function uploadFile(file, uploadUrl) {
  const chunkSize = 1024 * 1024; // 1MB
  const totalChunks = Math.ceil(file.size / chunkSize);

  for (let i = 0; i < totalChunks; i++) {
    const start = i * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end);

    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('index', i);
    formData.append('total', totalChunks);
    formData.append('filename', file.name);

    await fetch(uploadUrl, {
      method: 'POST',
      body: formData
    });

    console.log(`Uploaded chunk ${i + 1}/${totalChunks}`);
  }
}

七、图片缩略图生成

7.1 Canvas 缩放

async function generateThumbnail(file, maxWidth = 200, maxHeight = 200) {
  // 读取图片
  const img = await createImageBitmap(file);

  // 计算缩放比例
  let width = img.width;
  let height = img.height;

  if (width > height) {
    if (width > maxWidth) {
      height *= maxWidth / width;
      width = maxWidth;
    }
  } else {
    if (height > maxHeight) {
      width *= maxHeight / height;
      height = maxHeight;
    }
  }

  // 绘制缩略图
  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;

  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0, width, height);

  // 转换为 blob
  return new Promise((resolve) => {
    canvas.toBlob(resolve, 'image/jpeg', 0.8);
  });
}

// 使用
const input = document.getElementById('fileInput');
input.addEventListener('change', async (e) => {
  const file = e.target.files[0];
  if (file && file.type.startsWith('image/')) {
    const thumbnail = await generateThumbnail(file);
    const url = URL.createObjectURL(thumbnail);
    document.getElementById('thumbnail').src = url;
  }
});

八、安全考虑

8.1 文件路径泄露

// ❌ 危险:暴露完整路径
console.log(file.webkitRelativePath);

// ✅ 安全:只获取文件名
console.log(file.name);

8.2 文件类型欺骗

// 用户可能伪造文件类型
// 不应该仅依赖 file.type 判断文件内容

// 应该检测文件内容(magic numbers)
async function detectFileType(file) {
  const buffer = await file.slice(0, 4).arrayBuffer();
  const bytes = new Uint8Array(buffer);

  // PNG: 89 50 4E 47
  if (bytes[0] === 0x89 && bytes[1] === 0x50) {
    return 'image/png';
  }

  // JPEG: FF D8 FF
  if (bytes[0] === 0xFF && bytes[1] === 0xD8) {
    return 'image/jpeg';
  }

  // PDF: 25 50 44 46
  if (bytes[0] === 0x25 && bytes[1] === 0x50) {
    return 'application/pdf';
  }

  return 'unknown';
}

参考资料

延展阅读