Skip to content

实现 ZIP在线打包与解压 工具

分类:developer | 标签:ZIP、解压、打包 本地解压 zip 与多文件打包下载,不上传服务器

2.1 功能说明

本地解压 zip 与多文件打包下载,不上传服务器

2.1.1 使用指南

功能说明

在浏览器里直接完成 ZIP 压缩包的制作与解压。选中的文件全部由本机 JavaScript 读取和处理, 不会上传到任何服务器,因此也没有文件大小的服务端限制,适合处理含敏感内容的资料。

打包

点击「添加文件」可多选文件;点击「添加文件夹」会连同子目录一起加入,并在压缩包内保留原有的目录结构。

压缩级别 0 表示仅存储不压缩(速度最快),9 表示最高压缩比(速度最慢),默认 6 是通用的平衡取值。已经压缩过的格式(如 JPG、MP4、PDF)提高级别收益很小。

列表中可以逐条移除误选的文件,确认无误后点击「打包并下载」即可得到 ZIP 文件。

解压

选择一个 .zip 文件后会解析出完整的目录树,点击文件夹可以展开或收起。

点击文件名可预览:文本类文件(txt、json、js、css、md、xml、csv 等)按 UTF-8 显示前 200KB 内容,图片类文件(png、jpg、gif、webp、svg 等)直接渲染缩略图,其余类型仅显示体积信息。

「提取」按钮可单独保存某个文件;「全部提取」在支持文件系统访问 API 的浏览器(Chrome、Edge)中会让你选择一个目标文件夹并还原完整目录结构,其他浏览器则会逐个触发下载。

注意事项

受浏览器内存限制,建议单个压缩包控制在数百 MB 以内。加密(带密码)的 ZIP 无法解析。

2.2 代码实现

2.2.1 组件结构

本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-inputel-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。

2.2.2 核心逻辑概览

<script setup> 中定义的主要函数/方法:

  • extOf()
  • iconOf()
  • formatSize()
  • downloadBlob()
  • onPickFiles()
  • doPack()
  • onPickZip()
  • buildRows()
  • toggleDir()
  • clearPreview()
  • openPreview()
  • extractOne()
  • extractAll()

2.2.3 关键实现代码

以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。

vue
import {computed, onUnmounted, reactive, ref} from 'vue'
import {ElMessage} from 'element-plus'
import ToolPageShell from '@/components/ToolPageShell.vue'
import JSZip from '@/vendor/jszip/index.js'

const activeTab = ref('pack')

/* ------------------------- 公共辅助 ------------------------- */

const TEXT_EXT = [
  'txt', 'md', 'markdown', 'json', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'css', 'scss', 'less',
  'html', 'htm', 'xml', 'svg', 'yml', 'yaml', 'ini', 'conf', 'cfg', 'properties', 'csv', 'tsv',
  'log', 'sql', 'sh', 'bat', 'java', 'py', 'go', 'rs', 'c', 'h', 'cpp', 'hpp', 'php', 'rb',
  'vue', 'env', 'gitignore', 'editorconfig'
]
const IMAGE_EXT = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'svg', 'avif']
const MAX_TEXT_PREVIEW = 200 * 1024

function extOf(name) {
  const i = name.lastIndexOf('.')
  return i === -1 ? '' : name.slice(i + 1).toLowerCase()
}

function iconOf(name) {
  const e = extOf(name)
  if (IMAGE_EXT.includes(e)) return '🖼️'
  if (TEXT_EXT.includes(e)) return '📄'
  return '📦'
}

function formatSize(bytes) {
  if (bytes === undefined || bytes === null) return '-'
  if (bytes < 1024) return bytes + ' B'
  const units = ['KB', 'MB', 'GB']
  let v = bytes / 1024
  let i = 0
  while (v >= 1024 && i < units.length - 1) {
    v /= 1024
    i++
  }
  return v.toFixed(v < 10 ? 2 : 1) + ' ' + units[i]
}

function downloadBlob(blob, filename) {
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = filename
  document.body.appendChild(a)
  a.click()
  document.body.removeChild(a)
  setTimeout(() => URL.revokeObjectURL(url), 1000)
}

/* ------------------------- 打包 ------------------------- */

const fileInput = ref(null)
const dirInput = ref(null)
const packList = ref([])
const zipName = ref('archive')
const compressLevel = ref(6)
const packing = ref(false)
const packProgress = ref(0)
const packError = ref('')
let keySeq = 0

const totalPackSize = computed(() => packList.value.reduce((s, i) => s + i.file.size, 0))

function onPickFiles(e) {
  packError.value = ''
  const files = Array.from(e.target.files || [])
  for (const file of files) {
    // 选择文件夹时 webkitRelativePath 带有相对路径,用它保留目录结构
    const path = file.webkitRelativePath || file.name
    if (packList.value.some(i => i.path === path)) continue
    packList.value.push({key: ++keySeq, path, file})
  }
  if (files.length && !zipName.value) zipName.value = 'archive'
  // 允许再次选择相同文件
  e.target.value = ''
}

async function doPack() {
  if (!packList.value.length) return
  packError.value = ''
  packing.value = true
  packProgress.value = 0
  try {
    const zip = new JSZip()
    for (const item of packList.value) {
      zip.file(item.path, item.file)
    }
    const level = compressLevel.value
    const blob = await zip.generateAsync(
        {
          type: 'blob',
          compression: level === 0 ? 'STORE' : 'DEFLATE',
          compressionOptions: {level: Math.max(1, level)}
        },
        meta => {
          packProgress.value = Math.round(meta.percent)
        }
    )
    const name = (zipName.value.trim() || 'archive').replace(/\.zip$/i, '')
    downloadBlob(blob, name + '.zip')
    ElMessage.success(`打包完成,压缩包大小 ${formatSize(blob.size)}`)
  } catch (err) {
    packError.value = '打包失败:' + (err.message || '未知错误')
  } finally {
    packing.value = false
  }
}

/* ------------------------- 解压 ------------------------- */

const zipInput = ref(null)
const loadingZip = ref(false)
const unpackError = ref('')
const zipMeta = ref(null)
const treeRows = ref([])
const expanded = reactive({})
const preview = ref(null)
const extractingAll = ref(false)
let zipInstance = null

const visibleRows = computed(() =>
    treeRows.value.filter(row => {
      // 父级目录中只要有一层是收起状态,该行就不显示
      const parts = row.path.split('/').filter(Boolean)
      for (let i = 1; i < parts.length; i++) {
        if (!expanded[parts.slice(0, i).join('/') + '/']) return false
      }
      return true
    })
)

async function onPickZip(e) {
  const file = (e.target.files || [])[0]
  e.target.value = ''
  if (!file) return

  loadingZip.value = true
  unpackError.value = ''
  treeRows.value = []
  zipMeta.value = null
  clearPreview()
  Object.keys(expanded).forEach(k => delete expanded[k])

  try {
    zipInstance = await JSZip.loadAsync(file)
    const rows = buildRows(zipInstance)
    treeRows.value = rows
    rows.filter(r => r.dir).forEach(r => {
      expanded[r.path] = true
    })
    zipMeta.value = {
      name: file.name,
      size: file.size,
      fileCount: rows.filter(r => !r.dir).length,
      dirCount: rows.filter(r => r.dir).length
    }
    if (!rows.length) unpackError.value = '压缩包为空,未解析到任何文件'
  } catch (err) {
    zipInstance = null
    unpackError.value = '解析失败:' + (err.message || '文件可能已损坏或带有密码保护')
  } finally {
    loadingZip.value = false
  }
}

// 把 JSZip 的扁平路径整理成带层级的有序列表(目录在前、同级按名称排序)
function buildRows(zip) {
  const dirs = new Set()
  const files = []

  zip.forEach((relativePath, entry) => {
    if (entry.dir) {
      dirs.add(relativePath.endsWith('/') ? relativePath : relativePath + '/')
      return
    }
    files.push(entry)
    const parts = relativePath.split('/')
    for (let i = 1; i < parts.length; i++) {
      dirs.add(parts.slice(0, i).join('/') + '/')
    }
  })

  const nodes = []
  dirs.forEach(path => {
    const parts = path.split('/').filter(Boolean)
    nodes.push({path, name: parts[parts.length - 1], depth: parts.length - 1, dir: true})
  })
  files.forEach(entry => {
    const parts = entry.name.split('/')
    nodes.push({
      path: entry.name,
      name: parts[parts.length - 1],
      depth: parts.length - 1,
      dir: false,
      size: entry._data ? entry._data.uncompressedSize : 0
    })
  })

  // 按完整路径排序,同一父目录下目录排在文件前面
  nodes.sort((a, b) => {
    const pa = a.path.replace(/\/$/, '').split('/')
    const pb = b.path.replace(/\/$/, '').split('/')
    const len = Math.min(pa.length, pb.length)
    for (let i = 0; i < len; i++) {
      if (pa[i] !== pb[i]) {
        const aIsDir = a.dir || i < pa.length - 1
        const bIsDir = b.dir || i < pb.length - 1
        if (aIsDir !== bIsDir) return aIsDir ? -1 : 1
        return pa[i].localeCompare(pb[i], 'zh-CN')
      }
    }
    return pa.length - pb.length
  })

  return nodes
}

function toggleDir(path) {
  expanded[path] = !expanded[path]
}

function clearPreview() {
  if (preview.value?.url) URL.revokeObjectURL(preview.value.url)
  preview.value = null
}

async function openPreview(row) {
  if (!zipInstance) return
  clearPreview()

  const ext = extOf(row.name)
  const kind = IMAGE_EXT.includes(ext) && ext !== 'svg'
      ? 'image'
      : (TEXT_EXT.includes(ext) ? 'text' : 'binary')

  preview.value = {path: row.path, size: row.size, kind, loading: true, truncated: false}

  try {
    const entry = zipInstance.file(row.path)
    if (!entry) throw new Error('文件不存在')

    if (kind === 'image') {
      const blob = await entry.async('blob')
      preview.value = {...preview.value, loading: false, url: URL.createObjectURL(blob)}
    } else if (kind === 'text') {
      let text = await entry.async('string')
      let truncated = false
      if (text.length > MAX_TEXT_PREVIEW) {
        text = text.slice(0, MAX_TEXT_PREVIEW)
        truncated = true
      }
      preview.value = {...preview.value, loading: false, text, truncated}
    } else {
      preview.value = {...preview.value, loading: false}
    }
  } catch (err) {
    preview.value = {...preview.value, loading: false, kind: 'binary'}
    ElMessage.error('读取失败:' + (err.message || '未知错误'))
  }
}

async function extractOne(row) {
  if (!zipInstance) return
  try {
    const entry = zipInstance.file(row.path)
    if (!entry) throw new Error('文件不存在')
    const blob = await entry.async('blob')
    downloadBlob(blob, row.name)
  } catch (err) {
    ElMessage.error('提取失败:' + (err.message || '未知错误'))
  }
}

async function extractAll() {
  if (!zipInstance) return
  const files = treeRows.value.filter(r => !r.dir)
  if (!files.length) return

  extractingAll.value = true
  try {
    if (typeof window.showDirectoryPicker === 'function') {
      const dirHandle = await window.showDirectoryPicker({mode: 'readwrite'})
      for (const row of files) {
        const parts = row.path.split('/')
        const fileName = parts.pop()
        let target = dirHandle
        for (const seg of parts) {
          target = await target.getDirectoryHandle(seg, {create: true})
        }
        const blob = await zipInstance.file(row.path).async('blob')
        const handle = await target.getFileHandle(fileName, {create: true})
        const writable = await handle.createWritable()
        await writable.write(blob)
        await writable.close()
      }
      ElMessage.success(`已还原 ${files.length} 个文件到所选文件夹`)
    } else {
      for (const row of files) {
        const blob = await zipInstance.file(row.path).async('blob')
        downloadBlob(blob, row.name)
        await new Promise(r => setTimeout(r, 150))
      }
      ElMessage.success(`已触发 ${files.length} 个文件的下载`)
    }
  } catch (err) {
    if (err.name !== 'AbortError') {
      ElMessage.error('提取失败:' + (err.message || '未知错误'))
    }
  } finally {
    extractingAll.value = false
  }
}

onUnmounted(clearPreview)

2.3 效果截图

ZIP在线打包与解压 效果截图

工具访问地址:https://www.i91tools.com/tools/zip-tool