Appearance
实现 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-input、el-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 效果截图
