纯前端实现OSS文件多级目录批量导出方案

引言:在现代Web应用开发中,我们常需处理云端文件与本地文件系统的交互需求。本文聚焦一个典型场景:​如何通过纯前端技术,将OSS(对象存储服务)中的文件按原有路径结构批量导出至用户本地设备。该方案需满足两个核心诉求:

  1. 保持云端文件层级关系(如oss://project/docs/2025/file1.pdf对应本地/project/docs/2025目录)
  2. 规避浏览器安全限制,实现一键式安全下载

 

问题挑战:浏览器的安全边界

浏览器作为沙箱化执行环境,出于安全考虑严格限制以下操作:

  • 禁止自由访问文件系统:无法直接读取/写入任意本地路径
  • 限制批量下载行为:传统下载方式会导致多文件弹窗频现
  • 阻断目录结构操作:无法以编程方式创建本地文件夹层级

这些限制使得传统的<a>标签下载或window.open方案难以满足需求,亟需探索新的技术路径。

 使用​File System API前端本地化处理文件下载案例截图(案例代码下拉到底部):

创建了文件并实时写入了数据

Web端多文件下载技术方案对比:

  • 后端集中式处理:

    • 实现逻辑:
      • 用户在前端选择目标文件/目录 → 发送批量下载请求 → 后端异步拉取OSS文件并构建目录结构 → 服务端生成ZIP压缩包 → 返回下载链接 → 前端通过<a>标签触发下载(典型应用:百度网盘Web端)
    • ✅ ​优势:

      • 服务端高性能处理,支持海量文件(如TB级数据)
      • 天然规避浏览器内存限制,稳定性强
      • 统一权限校验与日志审计,符合企业级安全要求
    • ❌ ​劣势:

      • 服务端资源消耗大(计算、存储、带宽成本)

      • 异步处理导致延迟(用户需等待打包完成)

      • 无法实现纯前端离线操作

  • ​前端本地化处理

    • 方案一:内存压缩流方案(JSZip类库)​

      • 技术路径:

        • 批量获取文件二进制流(Blob)
        • 在内存中构建虚拟文件树(模拟目录层级)
        • 使用JSZip生成ZIP包并触发浏览器下载
      • ✅ ​优势:
        • 纯前端实现,零服务端依赖
        • 实现轻量化,对小文件场景友好(<500MB)
        • 开发成本低(现有库如JSZip/SaveAs成熟易用)
      • ❌ ​劣势:

        • 内存瓶颈显著(大文件易引发OOM崩溃)
        • 深层目录处理效率低(递归算法性能衰减)
        • 无断点续传机制,网络中断需全量重试
      • 案例:
      // 先封装一个方法,请求返回文件blob
      async function fetchBlob(fetchUrl, method = "POST", body = null) {
          const response = await window.fetch(fetchUrl, {
              method,
              body: body ? JSON.stringify(body) : null,
              headers: {
                  "Accept": "application/json",
                  "Content-Type": "application/json",
                  "X-Requested-With": "XMLHttpRequest",
              },
          });
          const blob = await response.blob();
          return blob;
      }
      
      const zip = new JSZip();
      zip.file("Hello.txt", "Hello World\n"); // 支持纯文本等
      
      zip.file("img1.jpg", fetchBlob('/api/get/file?name=img.jpg', 'GET')); // 支持Promise类型,需要返回数据类型是 String, Blob, ArrayBuffer, etc
      zip.file("img2.jpg", fetchBlob('/api/post/file', 'POST', { name: 'img.jpg' })); // 同样支持post请求,只要返回类型正确就行
      
      const folder1 = zip.folder("folder01"); // 创建folder
      folder1.file("img3.jpg", fetchBlob('/api/get/file?name=img.jpg', 'GET')); // folder里创建文件
      
      zip.generateAsync({ type: "blob" }).then(blob => {
          const url = window.URL.createObjectURL(blob);
          downloadFile(url, "test.zip");
      });
    • 方案二:使用File System API

      • 技术路径:

        • 调用window.showDirectoryPicker()获取用户授权

        • 创建沙盒化虚拟文件系统(非真实磁盘路径)

        • 通过Streams API流式写入文件并保持路径元数据

      • ✅ ​优势:

        • ​用户控制:通过显式授权机制保障用户知情权,避免隐蔽操作风险
        • ​本地化交互:支持本地文件实时编辑与保存,减少上传/下载环节
        • ​大文件友好:基于文件流的分块写入机制降低内存压力
      • ❌ ​劣势:

        • ​兼容性局限:仅Chromium内核浏览器全功能支持(Chrome/Edge ≥ 86)
        • ​权限模型复杂:需处理权限持久化(如handle.persist()
        • ​沙箱隔离:生成文件无法直接访问系统路径(需用户手动导出)

方案选型建议:

维度 后端方案 前端JSZip方案 前端File API方案
适用场景 企业级海量数据 中小型即时导出 本地编辑型应用
性能边界 无上限 <500MB <5GB
安全等级 最高
体验成本 延迟感知 内存敏感 浏览器兼容敏感

File System API使用介绍:

File System API(文件系统 API)​ 是浏览器提供的一组 JavaScript 接口,允许网页在用户明确授权的前提下,以安全可控的方式访问本地文件系统。它的核心目标是解决传统 Web 应用无法直接操作本地文件的痛点(如下载文件路径不可控、无法批量处理文件夹等),同时通过沙箱化机制确保用户隐私和数据安全。

File System API 的核心功能

  1. 读写本地文件

    • 通过浏览器弹窗获取用户授权后,可读写用户指定的文件或目录
    • 支持流式读写(FileSystemWritableFileStream),避免大文件内存溢出
  2. 创建/管理沙盒文件系统

    • Origin Private File System(源私有文件系统)​:浏览器为每个网站分配独立的虚拟存储空间(类似 Cookie),用于临时存储文件
    • ​用户授权访问的真实文件系统:通过 API 访问用户选择的本地真实目录(如桌面、下载文件夹等)
  3. 目录层级操作

    • 创建嵌套目录、遍历文件树、批量操作文件(移动/复制/删除)
  4. 权限控制

    • 所有操作需用户主动授权(如选择文件、目录或保存位置)
    • 权限可设置为临时(单次会话有效)或持久化(下次访问自动继承)

核心 API 方法

方法名 用途
window.showOpenFilePicker() 弹出文件选择器,获取用户选择的文件句柄(FileSystemFileHandle
window.showSaveFilePicker() 弹出保存对话框,获取用户指定的保存位置文件句柄
window.showDirectoryPicker() 弹出目录选择器,获取用户选择的文件夹句柄(FileSystemDirectoryHandle
fileHandle.getFile() 通过句柄获取文件对象(File
directoryHandle.getFileHandle() 在目录中创建或获取文件句柄
directoryHandle.getDirectoryHandle() 在目录中创建或获取子目录句柄

与传统文件操作的对比

能力 <input type="file"> File System API
读取文件 ✅ 单文件 ✅ 多文件/目录
写入文件 ✅ 可指定路径保存
目录操作 ✅ 创建/遍历/管理
权限持久化 ✅ 下次访问无需重复授权
大文件支持 ❌ 内存限制 ✅ 流式读写

批量导出文件操作流程:

  1. 初始化批量下载任务

    • 用户在前端界面选择需要下载的OSS文件/目录列表(支持多选与层级展开)
    • 触发下载指令,启动文件处理流程
  2. 获取本地目录授权

    • 调用 window.showDirectoryPicker() 显示浏览器原生目录选择对话框
    • 用户显式授权目标存储路径(如选择桌面或自定义下载文件夹)
    • 获取持久化目录句柄 FileSystemDirectoryHandle
  3. 按需构建本地目录结构

    • 解析OSS文件路径元数据(如 oss://project/docs/report.pdf
    • 使用 getDirectoryHandle() 递归创建对应的本地嵌套目录(/project/docs/
    • 通过 getFileHandle() 初始化目标文件(report.pdf
  4. 流式数据写入

    • 通过网络请求OSS文件数据流
    • 创建可写流 FileSystemWritableFileStream
    • 实时将接收到的二进制数据块(Uint8Array)写入本地文件
    • 同步更新下载进度(已传输字节数/文件总大小)
  5. 事务完整性校验

    • 对比本地文件大小与OSS记录的原始文件大小
    • 可选哈希校验(如MD5/SHA-256)确保数据一致性
    • 错误重试机制(针对网络中断等异常场景)

本地案例代码:(代码由DeepSeek编写)

以下是一个基于 Node.js 本地服务代理 + 前端 File System API 的实现方案,适用于开发环境下的 OSS 文件模拟下载场景

  • 使用node.js启动服务,主要用于请求文件数据,并返回数据和进度
const express = require('express');
const axios = require('axios');
const cors = require('cors');
const path = require('path');

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

// 使用 cors 中间件
app.use(cors());

// 示例的OSS端点
const ossEndpoint = 'oss储存文件的地址';

app.get('/get-oss-link', async (req, res) => {
  try {
    const response = await axios({
      url: ossEndpoint,
      method: 'GET',
      responseType: 'stream',
      onDownloadProgress: progressEvent => {
        const { loaded, total } = progressEvent;
        const progress = total ? (loaded / total) * 100 : 0;
        console.log(`Download progress: ${progress}%`);
      }
    });

    console.log('Response headers from OSS:', response.headers);

    const contentDisposition = response.headers['content-disposition'];
    let fileName = 'unknown';

    if (contentDisposition) {
      const fileNameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
      if (fileNameMatch != null && fileNameMatch[1]) {
        // Decode the filename
        fileName = decodeURIComponent(fileNameMatch[1].replace(/['"]/g, ''));
      }
    }

    if (fileName === 'unknown') {
      const parsedUrl = new URL(ossEndpoint);
      fileName = path.basename(parsedUrl.pathname);
    }

    res.set({
      'Content-Type': response.headers['content-type'],
      'Content-Length': response.headers['content-length'],
      'Content-Disposition': `attachment; filename=${fileName}`,
      'Access-Control-Expose-Headers': 'Content-Disposition'
    });

    // 这里我们监听数据流事件来监控下载进度
    let dataLength = 0;
    const totalLength = parseInt(response.headers['content-length'], 10);

    response.data.on('data', chunk => {
      dataLength += chunk.length;
      const progress = totalLength ? (dataLength / totalLength) * 100 : 0;
      console.log(`Download progress: ${progress.toFixed(2)}%`);
    });

    response.data.pipe(res);
  } catch (error) {
    console.error('Error fetching OSS link:', error);
    res.status(500).json({ error: 'Failed to fetch OSS link' });
  }
});

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});
  •  web调用API并写入数据
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>文件系统API操作示例</title>
</head>

<body>
  <!-- 用户操作界面 -->
  <button id="createFileButton">下载文件到指定目录</button>
  <!-- 下载进度指示器 -->
  <progress id="download-progress" value="0" max="100" style="width: 100%;"></progress>
  <div id="download-progress-text">下载进度: 0%</div>

  <script>
    // 主操作流程 ===================================================
    document.getElementById('createFileButton').addEventListener('click', async () => {
      const start = performance.now();

      try {
        // 阶段1: 获取目录权限
        const dirHandle = await requestDirectoryAccess();

        // 阶段2: 创建下载目录
        const targetDirHandle = await createDownloadDirectory(dirHandle);

        // 阶段3: 获取云端文件
        const fileResponse = await fetchOSSFile();

        // 阶段4: 解析文件信息
        const { fileName, fileSize } = await analyzeFileMetadata(fileResponse);

        // 阶段5: 创建本地文件
        const fileHandle = await initializeLocalFile(targetDirHandle, fileName);

        // 阶段6: 流式写入数据
        await streamDataToFile(fileHandle, fileResponse, fileSize);

      } catch (error) {
        handleOperationError(error);
      } finally {
        // 性能监控日志
        const duration = performance.now() - start;
        console.log(`[性能] 操作耗时: ${duration.toFixed(2)}ms`);
      }
    });

    // 核心功能函数 ================================================

    /**
     * 请求文件系统访问权限
     * @returns {Promise<FileSystemDirectoryHandle>}
     */
    async function requestDirectoryAccess() {
      try {
        return await window.showDirectoryPicker({
          mode: 'readwrite', // 明确请求读写权限
          startIn: 'downloads' // 建议起始目录(部分浏览器支持)
        });
      } catch (error) {
        throw new Error(`目录访问被拒绝: ${error.message}`);
      }
    }

    /**
     * 创建下载专用目录
     * @param {FileSystemDirectoryHandle} parentDir 
     * @returns {Promise<FileSystemDirectoryHandle>}
     */
    async function createDownloadDirectory(parentDir) {
      try {
        return await parentDir.getDirectoryHandle('oss-downloads', {
          create: true, // 自动创建目录
          recursive: true // 确保递归创建(如果父目录不存在)
        });
      } catch (error) {
        throw new Error(`目录创建失败: ${error.message}`);
      }
    }

    /**
     * 获取云端文件数据
     * @returns {Promise<Response>}
     */
    async function fetchOSSFile() {
      try {
        const controller = new AbortController();
        const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时

        const response = await fetch('http://localhost:3000/get-oss-link', {
          signal: controller.signal
        });

        clearTimeout(timeoutId);

        if (!response.ok) {
          throw new Error(`请求失败: HTTP ${response.status}`);
        }
        return response;
      } catch (error) {
        throw new Error(`文件获取失败: ${error.message}`);
      }
    }

    /**
     * 解析文件元数据
     * @param {Response} response 
     * @returns {Promise<{fileName: string, fileSize: number}>}
     */
    async function analyzeFileMetadata(response) {
      // RFC 6266标准的文件名解析
      const disposition = response.headers.get('Content-Disposition') || '';
      const filenameRegex = /filename\*?=(?:utf-8''|")?([^";]+)/i;
      const matchResult = disposition.match(filenameRegex);

      // 安全获取文件名
      let filename = matchResult?.[1]
        ? decodeURIComponent(matchResult[1])
        : new URL(response.url).pathname.split('/').pop() || '未命名文件';

      // 消毒处理文件名
      filename = sanitizeFilename(filename);

      // 获取文件尺寸
      const fileSize = Number(response.headers.get('Content-Length')) || 0;

      console.log(`[元数据] 文件名: ${filename}, 大小: ${formatBytes(fileSize)}`);

      return { fileName: filename, fileSize };
    }

    /**
     * 初始化本地文件
     * @param {FileSystemDirectoryHandle} dirHandle 
     * @param {string} fileName 
     * @returns {Promise<FileSystemFileHandle>}
     */
    async function initializeLocalFile(dirHandle, fileName) {
      try {
        return await dirHandle.getFileHandle(fileName, {
          create: true, // 创建新文件
          keepExistingData: false // 覆盖已存在文件
        });
      } catch (error) {
        throw new Error(`文件创建失败: ${error.message}`);
      }
    }

    /**
     * 流式写入文件数据
     * @param {FileSystemFileHandle} fileHandle 
     * @param {Response} response 
     * @param {number} totalSize 
     */
    async function streamDataToFile(fileHandle, response, totalSize) {
      const progressBar = document.getElementById('download-progress');
      const progressText = document.getElementById('download-progress-text');
      let receivedBytes = 0;
      let lastUpdate = 0; // 用于节流进度更新

      try {
        const writable = await fileHandle.createWritable({ keepExistingData: false });
        const reader = response.body.getReader();

        while (true) {
          const { done, value } = await reader.read();
          if (done) break;

          receivedBytes += value.length;
          await writable.write(value);

          // 节流进度更新(每秒最多60次)
          const now = Date.now();
          if (now - lastUpdate > 16) {
            updateProgressIndicator(receivedBytes, totalSize, progressBar, progressText);
            lastUpdate = now;
          }
        }

        // 最终进度同步
        updateProgressIndicator(receivedBytes, totalSize, progressBar, progressText);
      } finally {
        if (writable) await writable.close();
      }
    }

    // 辅助工具函数 ================================================

    /**
     * 统一错误处理
     * @param {Error} error 
     */
    function handleOperationError(error) {
      console.error('[错误]', error);
      alert(`操作失败: ${error.message.replace(/^[^:]+: /, '')}`);
    }

    /**
     * 更新进度指示器
     * @param {number} current 
     * @param {number} total 
     * @param {HTMLElement} bar 
     * @param {HTMLElement} text 
     */
    function updateProgressIndicator(current, total, bar, text) {
      const percentage = total ? Math.min((current / total) * 100, 100) : 0;
      bar.value = percentage;
      text.textContent = `下载进度: ${percentage.toFixed(1)}% - ${formatBytes(current)}/${formatBytes(total)}`;
    }

    /**
     * 文件名消毒处理
     * @param {string} name 
     * @returns {string}
     */
    function sanitizeFilename(name) {
      return name
        .replace(/[/\\?*:|"<>]/g, '_') // 替换非法字符
        .normalize('NFC')              // 统一Unicode格式
        .substring(0, 200)             // 限制最大长度
        .trim() || '未命名文件';        // 处理空文件名
    }

    /**
     * 字节格式化工具
     * @param {number} bytes 
     * @returns {string}
     */
    function formatBytes(bytes) {
      if (bytes === 0) return '0 B';
      const units = ['B', 'KB', 'MB', 'GB'];
      const exponent = Math.floor(Math.log(bytes) / Math.log(1024));
      return `${(bytes / 1024 ** exponent).toFixed(2)} ${units[exponent]}`;
    }
  </script>
</body>

</html>

案例实现:用户授权可操作的文件夹 → 创建保存下载文件的目录 → 请求文件数据 → 解析文件信息 → 创建本地文件 → 流式写入数据 

总结:基于浏览器 File System Access API,实现目录体系构建文件全生命周期管理流式数据写入三大核心能力。在实际业务场景中可根据API提供的能力进行定制化开发,实现多种文件交互的复杂场景。