☰
Cocos Creator ZIP解压实战:JSZip选型、跨平台落盘与避坑指南
2026/10/8 9:02:55 网站建设 项目流程

简介:面向Cocos Creator开发者的ZIP文件处理示例资源,聚焦于JavaScript环境下结合JSZip库完成压缩包的读取、解压与创建操作,适合需要实现游戏资源增量更新、扩展内容下载或本地存档系统的开发者。压缩包共25个文件,大小仅43KB,文件类型以js脚本、json配置、cpp/hpp原生绑定代码以及meta、fire等Cocos Creator工程文件为主,覆盖ZIP处理的实现逻辑与项目配置。目前已有1145人学习下载。资源虽小但结构清晰,从ZIP基础概念、JSZip引入方式,到loadAsync解压、generateAsync生成均有对应代码示例,并针对Web端Blob限制、文件路径匹配、跨平台兼容等常见问题给出处理思路。对于想在Cocos Creator中快速上手ZIP功能、减少踩坑的开发者来说,这份小巧的示例包具有较高参考价值。

1. Cocos Creator 里的 ZIP 文件处理:引擎没给现成解压器,你得自己扛

做 Cocos Creator 项目的人,迟早会撞上 ZIP 文件处理。运营要换一波活动图,你不想为十几张图发一次包;业务系统导出的货运单据、点菜数据、关卡配置,全部以 zip 形式塞过来;甚至你自己从网上拖回来的源码包和资源包,解不开就只能干瞪眼。反直觉的结论是:Creator 从 2.x 到 3.x 都没有内置 zip 解压能力,构建打包 apk 时也不会帮你处理运行时 ZIP,编辑器对 zip 的支持仅限于“导入资源包”那一下。这意味着所有解压逻辑你都要自己选库、自己跨平台排雷。这篇内容就把选型、最小可用链路、动态加载和踩过的坑一次说清,适合正在做资源包更新、外部数据导入和微信小游戏兼容的团队参考。

2. 解压库选型与集成:为什么我最后选了 JSZip,以及两条接入路径

2.1 动手前先回答一个问题:你要在哪里处理 ZIP

同样是 ZIP,场景不同,方案完全不同。如果是编辑器内导入资源,Creator 的资源管理器本身能处理标准资源包 zip,你不需要写代码;但绝大多数人搜到这里,是因为运行时需要解压——游戏启动后从远程拉一个资源包,或者读本地沙盒里的业务数据。这时代码跑在浏览器、微信小游戏、Android/iOS 的 JS 引擎里,没有系统 unzip 命令可调,必须引入纯 JS 的解压库。

还有一种情况是离线工具链处理:在构建机或 CI 里把资源打成 zip、给 zip 加包头、做校验。这种走 Node 或 Python 都行,跟游戏运行时无关,但我在第 6 章会提到一个技巧,它需要打包端和游戏端配合,所以选型时要把打包脚本的语言也一起定下来。我一般建议:游戏端用 JSZip,打包脚本用 Node,两头都是 JS 生态,符号和逻辑能保持一致,少踩一种语言差异的坑。

2.2 JSZip 和 zip.js、原生解压、自写解析的取舍

我在项目里比较过四条路,结论可以直接抄:常规项目无脑选 JSZip。它的体积在纯 JS 解压库里面算克制——gzip 后大约 30KB,支持解压也支持压缩,API 是 Promise 风格,和 Creator 3.x 的 TypeScript 环境配合很顺。zip.js 的流式能力更强,浏览器端还有 Worker 加持,但体积更大,原生平台和小游戏环境的适配要自己验证,除非你的包大到必须流式解压,否则不值得换。

系统原生解压是性能上限最高的方案,Android 写 Java/Kotlin 插件、iOS 写 Objective-C/Swift,JS 层通过 bridge 调用。问题是维护成本翻倍:换一个人就要重新摸一遍原生侧逻辑,而且 zip 里的中文文件名、加密、分卷这些边界在两端可能表现不一致。只有压缩包常年在 500MB 以上、且团队有原生开发资源时,我才建议走这条路。至于自写 ZIP 解析,几百行能读出最简单的包,但 ZIP 协议里的数据描述符、Zip64、加密头、目录偏移任意一个坑都能让你排查三天,属于典型的“看着简单,落地翻车”,不碰。

下面是我当时的选型记录,参数都标在表里:

方案平台覆盖体积流式解压维护成本结论
JSZip浏览器/小游戏/原生 JS约 30KB gzip不支持低默认首选
zip.js浏览器最佳,原生需自测较大支持中Web 为主可选
系统原生插件Android/iOS不计支持高超大包才考虑
自写解析器全平台但易碎极小难极高不推荐

最终选型理由就一句话:大多数手游的运营资源包在 10MB 到 100MB 之间,JSZip 的内存模型完全扛得住,为这个体量去养一套原生解压代码不划算。

2.3 把 JSZip 装进 Cocos Creator:npm 和插件脚本两条路

Creator 3.x 的项目根目录本来就有 package.json,直接按 npm 依赖装:

npm install jszip --save npm install -D @types/jszip --save-dev

装完后在 TypeScript 里引入即可:

import JSZip from 'jszip';

Creator 构建时会把 npm 依赖一起打包进 bundle,不需要额外配置。要注意的是 @types/jszip 只是类型声明,如果项目里禁用了 npm 的自动类型查找,会在编辑器里报找不到模块的类型,但运行时不受影响。

如果是 2.x 项目,或者 3.x 项目不想引入 npm 构建链路,我一般把node_modules/jszip/dist/jszip.min.js拷贝到assets/scripts/vendor/下,然后在脚本里直接require('jszip')。更省事的做法是在编辑器里把它挂成插件脚本,JSZip 会作为全局变量先于游戏脚本加载,缺点是全局命名空间被占一个,团队里有人不知道这个变量从哪来,排查时会懵一下。两条路我都留过坑:npm 方式在构建时如果没开“启用 npm”或版本不兼容会静默失败,插件脚本方式在微信小游戏的首包扫描里要确认 jszip.min.js 被正确包含,否则线上才报“JSZip is not defined”。

3. 跑通最小解压链路:拉二进制、解析条目、跨平台落盘

3.1 资源放哪:远程 URL、resources 目录还是本地沙盒

ZIP 文件放在不同位置,读取方式完全不同。放在 resources 里,随包发布,问题是最初就把 zip 打进了安装包,那“用 zip 避免整包更新”的意义就没了。放在远程 CDN,是最常见的运营资源包场景,游戏启动后按版本号下载。放在本地沙盒,一般是之前下载过想复用,或者外部通过文件分享导入的 zip。我的判断标准很简单:zip 只要不是随包发布的,一律先下到应用沙盒再解压,不要让解压逻辑同时处理“网络流”和“本地文件”两种输入,统一成 ArrayBuffer 一种形态,后续好维护。

3.2 用 XMLHttpRequest 拉二进制,别让 responseType 坑你

拿 URL 拉 zip 时,最容易翻车的就是 responseType。有人用 fetch 直接在微信小游戏环境跑不通,有人用 XMLHttpRequest 忘了设二进制模式,拿回来的 response 变成字符串,一传给 JSZip 就出乱码或爆栈。我习惯封装一个下载函数:

function downloadBinary(url: string): Promise<ArrayBuffer> { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', url, true); xhr.responseType = 'arraybuffer'; // 关键:必须是二进制,默认 text 会毁掉 zip xhr.timeout = 15000; // 弱网环境下不设超时,会一直挂着 xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(xhr.response as ArrayBuffer); } else { reject(new Error(`HTTP ${xhr.status}: ${url}`)); } }; xhr.onerror = () => reject(new Error('network error')); xhr.ontimeout = () => reject(new Error('timeout')); xhr.send(); }); }

逻辑说明:responseType = 'arraybuffer'保证返回的是二进制 ArrayBuffer,后续交给 JSZip 的loadAsync才能正确解析;timeout设 15 秒,避免弱网下用户看到一个永远转不完的菊花。这里的参数按你的业务调整:如果 zip 在 CDN 上有预检分片,可以缩短到 10 秒;如果游戏内网络环境较差,拉长到 20 秒。补一句:请求失败后不要立刻重试,先退避 2 秒再试,这属于下载层的常规操作。

3.3 解压并逐文件落盘:兼容 2.x 与 3.x 的写文件封装

拿到 ArrayBuffer 后,解压本身不复杂,麻烦的是写入。核心函数是这样的:

async function unzipBuffer(buffer: ArrayBuffer, saveDir: string) { const zip = await JSZip.loadAsync(buffer); // 解析 zip 目录,此刻不解压文件内容 const names = Object.keys(zip.files).filter((n) => !zip.files[n].dir); for (const name of names) { if (name.includes('..') || name.startsWith('/')) { // 防路径穿越 console.warn(`skip unsafe entry: ${name}`); continue; } const data = await zip.files[name].async('uint8array'); // 逐个解压,避免内存峰值叠加 const fullPath = joinPath(saveDir, name); ensureDir(dirnameOf(fullPath)); writeFileToNative(fullPath, data); } }

逻辑说明:loadAsync只解析中央目录,不会把文件内容一次性都解出来;async('uint8array')按条目取数据,for 循环串行执行,能让上一个文件的引用在循环结束后被回收,内存峰值可控。过滤..和绝对路径是必须的,业务 zip 里塞一个../../evil的条目,解压时就能写到沙盒外面,这是安全审计时一定会盯的点。

写文件接口在不同引擎版本差异很大,我项目里用的兼容封装长这样:

function writeFileToNative(fullPath: string, data: Uint8Array) { const jsbAny = (globalThis as any).jsb; if (jsbAny && jsbAny.fileUtils) { // 2.x / 3.x 早期:jsb.fileUtils jsbAny.fileUtils.writeDataToFile(data, fullPath); } else { // 3.x 较新版本:native.fs native.fs.writeFileSync(fullPath, data); } }

这段多说一句:引擎 2.x 到 3.x 的过渡期,文件 API 换过一次名字,如果你的编辑器控制台出现writeDataToFile is not a function或native.fs.writeFileSync is not a function,说明版本分支走错了,去看一眼jsb.fileUtils是否存在即可。目录创建我也放在ensureDir里封装:先jsb.fileUtils.isDirectoryExist判断,不存在就createDirectory,原生平台这一布漏掉,写文件时直接报目录不存在。

3.4 微信小游戏平台的落盘差异

小游戏没有jsb.fileUtils,也没有 Node 的fs,必须走微信自己的文件系统接口,而且可写目录只有一个:wx.env.USER_DATA_PATH。我推荐的写法是:

function writeFileOnWechat(relativePath: string, data: Uint8Array) { const fs = wx.getFileSystemManager(); const baseDir = wx.env.USER_DATA_PATH; const dir = baseDir + relativePath.substring(0, relativePath.lastIndexOf('/')); if (!fs.accessSync(dir)) { fs.mkdirSync(dir, true); // 第二个参数 true 表示递归创建 } fs.writeFileSync(baseDir + relativePath, data.buffer, 'binary'); // 注意是 data.buffer }

参数说明:data.buffer而不是data,因为小游戏写文件接口接收的是 ArrayBuffer,Uint8Array 是它的视图,直接传视图会出类型错误;mkdirSync(dir, true)的递归参数不传或传 false,多级目录直接失败;所有路径必须挂在USER_DATA_PATH下,写别的地方大概率没权限。这里的习惯是,把writeFile、ensureDir、joinPath全部收进一个文件系统适配层,业务代码不要出现任何wx或jsb分支,以后接抖音小游戏、快手小游戏,只改适配层就够了。

4. 把解压结果喂给 Creator:文本、图片、音频与事件通知

4.1 文本与 JSON:读完直接业务消费,注意 BOM

配置文件和数据文件是 zip 里最常见的角色。读取时直接拿 JSZip 转字符串:

const content = await zip.files['config.json'].async('string'); const config = JSON.parse(content.replace(/^\uFEFF/, '')); // 去 BOM

逻辑说明:async('string')会按 UTF-8 解码,这是 JSZip 默认行为。replace(/^\uFEFF/, '')是给 Excel、记事本等工具导出的 JSON 准备的,那些文件头部经常带一个 BOM 字符,JSON.parse直接抛错,这在业务数据 zip 里出现频率不低。顺便提醒:如果业务系统导出的 zip 不是 UTF-8 编码,这里读出来就是乱码,处理方案在第 5 章避坑里专门讲。

4.2 图片:原生平台用 assetManager.loadNative,Web 用 Image + ImageAsset

解压出来的图片要变成 SpriteFrame,有两套路径。Web 端和小游戏端,我直接用 Blob 中转:

import { ImageAsset, SpriteFrame, Texture2D } from 'cc'; const blob = await zip.files['img/bg.png'].async('blob'); const url = URL.createObjectURL(blob); const img = new Image(); img.onload = () => { const imageAsset = new ImageAsset(img); const texture = new Texture2D(); texture.image = imageAsset; const sf = new SpriteFrame(); sf.texture = texture; sprite.spriteFrame = sf; URL.revokeObjectURL(url); // 用完释放 Blob URL,否则会累积 }; img.src = url;

原生平台不要走 Image,JS 引擎的原生适配对 file:// 路径支持不稳定,我用assetManager.loadNative加载已经落盘的文件:

const absolutePath = fileUtilsPath + 'img/bg.png'; assetManager.loadNative<ImageAsset>({ url: absolutePath, ext: '.png' }, (err, asset) => { if (err) { console.error(err); return; } const texture = new Texture2D(); texture.image = asset; const sf = new SpriteFrame(); sf.texture = texture; sprite.spriteFrame = sf; });

参数说明:loadNative的ext必须显式传.png或.jpg,它靠扩展名派发到图片加载器;url用绝对路径而不是 file:// 前缀。内存管理方面,旧 SpriteFrame 的texture.destroy()和imageAsset的引用要记得处理,否则运营每换一次活动资源,内存涨一截,低端机跑两周就白屏。

4.3 音频与预制体:别在解压层玩魔法

音频的处理方式和图片类似,解压成文件后用assetManager.loadNative({ url, ext: '.mp3' })加载 AudioClip。但预制体千万不要妄想在运行时解压一个 prefab 出来直接用——原因很现实:Creator 场景和预制体依赖序列化文件加 UUID 引用,一个 prefab 往往牵出材质、图集、动画、组件脚本,脱离了 AssetBundle 的资源映射关系,你解压出来的只是一堆孤儿文件,不是可实例化的对象。正确的架构是:zip 里放原始数据或貼图、音频等原始资源,Prefab 继续走 AssetBundle 更新流程;zip 只在需要动态拼界面、换皮肤、灌数据时发挥作用。把这两件事混在一起,是我见过最多人踩的火坑。

4.4 用事件把“zip 处理完毕”通知给业务层

解压是异步大任务,UI 层不该在代码里 await 到底。我习惯在解压完成后发射一个全局事件:

import { EventTarget } from 'cc'; export const UNZIP_DONE = 'unzip_done'; export const zipEventTarget = new EventTarget(); // 解压完成后 zipEventTarget.emit(UNZIP_DONE, { names: fileList, elapsed: Date.now() - startTime, });

业务层订阅这个事件后再去拉取列表、刷新界面。参数这里有个细节:事件带上fileList作为快照,而不是让订阅方自己再读一次文件系统——因为另一个 zip 处理任务可能已经改了目录内容。用事件而不是回调,是为了避免 UI 脚本持有解压模块的强引用,卸载界面时清理订阅也简单。

5. 避坑排查:could not find eocd、中文乱码与大包闪退的现场记录

5.1 导入资源包失败 caused by invalid zip archive: could not find eocd

现象:编辑器里导入资源包直接弹这个错;运行时 JSZip 的loadAsync也会抛一模一样的invalid zip archive: could not find eocd。

原因:EOCD(End of Central Directory Record)是 ZIP 文件格式协议规定的尾部记录,固定以0x06054b50开头,解析器靠它定位中央目录。找不到 EOCD,基本只有三种情况:文件被截断、下载响应没读完就开始解析、或者文件根本是 HTML 错误页伪装成的 zip。网盘里转存的资源包、CDN 上被压缩过的 zip,都容易出这种问题。

解决:解压前先做一个快速体检。检查文件头两个字节是不是PK(0x50 0x4B),再检查尾部是否带 EOCD:

function hasEocd(buf: ArrayBuffer): boolean { const u8 = new Uint8Array(buf); if (u8.length < 22) return false; // EOCD 最小长度就是 22 字节 const i = u8.length - 22; return u8[i] === 0x50 && u8[i+1] === 0x4b && u8[i+2] === 0x05 && u8[i+3] === 0x06; // PK\x05\x06 }

逻辑说明:这个检查只能拦截“截断包”和“非 zip 包”,拦不住“中央目录都完好但某个文件内容损坏”的脏包,后者要依赖解压时的 CRC 校验。Zip64 格式的 EOCD 位置略有不同,但常规资源包很少触发,作为快速自检够用了。

5.2 中文文件名解压成乱码

现象:解压出来的文件名变成灏忓崱.png这类诡异字符,资源加载全部扑街。

原因:ZIP 规范里有个语言编码标志位没有强制,压缩时如果打包工具不置位,文件名默认按系统本地编码保存。中文世界里这个“本地编码”几乎就是 GBK/GB2312,而 JSZip 默认按 UTF-8 解码,于是中文名全乱。

解决:先看zip.files[name].utf8属性,它为 false 时说明原包不是 UTF-8。现代浏览器环境可以试:

const bytes = new Uint8Array(Array.from(name).map((c) => c.charCodeAt(0) & 0xff)); const realName = new TextDecoder('gbk').decode(bytes);

但这条在原生和小游戏环境不保险,TextDecoder('gbk')不是所有平台都带。我的血泪经验是:代码兜底不如上游修正。要求打包工具统一用 7-Zip 并勾选 UTF-8,或直接用 Info-ZIP 的zip -r归档,文件名就是标准的 UTF-8。货运单据导出、餐饮点菜系统这类第三方业务 zip,文件名十有八九是 GBK,跟对方提“导出时把文件名编码改成 UTF-8”比在客户端做一辈子兼容划算得多。

5.3 大压缩包让低端机闪退

现象:300MB 的资源包在测试机解压时直接杀进程,iOS 上先弹内存警告再闪退。

原因:JSZip.loadAsync要把整个压缩包读成 ArrayBuffer,解压时每个条目又产生一份解压后数据,内存峰值大概等于“压缩包大小 + 最大解压文件大小的若干倍”。低端 Android 机型可用内存只有几百 MB,再叠上引擎和纹理占用,必挂。

解决:把单包体量控制在 100MB 以内,超过就拆包;解压时用 for 循环串行读条目,不用Promise.all并发解压;处理完一个 entry 后立即把引用置空,别保存在数组里。还有一条约束:不要在解压的同时创建大量 SpriteFrame,解压和资源创建分两个阶段做,中间隔一帧,给内存一个喘息窗口。真到了非流式不可的规模,参考第 6 章的分包思路。

5.4 微信小游戏里没有你习惯的 fs API

现象:同一套解压代码在浏览器预览正常,构建成微信小游戏后一写文件就报错。

原因:小游戏环境没有 Node 的fs,也没有jsb.fileUtils,文件系统只有微信自己的一套接口,且可写目录仅限于wx.env.USER_DATA_PATH,包内路径只读。

解决:把“解压 + 写文件”整体收进适配层,平台差异不外泄。小游戏分支用wx.getFileSystemManager(),写之前先mkdirSync(dir, true),写入时传data.buffer而不是 Uint8Array。这个适配层我一般放在一个独立 ts 文件里,接口只暴露initSaveDir、writeFile、readFile,上层代码完全不知道自己在什么平台。代码里出现超过三处wx.或jsb.分支,就说明适配层拆得不够干净。

5.5 远程包下载一半就校验:先看尾字节

现象:下载过程没报错,一解压就 CRC error 或 EOCD 找不到,重试一次又好了。

原因:CDN 在传输中被中间设备掐断,或返回的 Content-Length 和实际 body 不一致,XHR 的 onload 仍然会触发,但数据已经是残缺的。

解决:下载完成后先比 Content-Length,再跑一次第 5.1 节的 EOCD 检查,最后让loadAsync顺带做 CRC32 校验——JSZip 解压条目时发现 CRC 不匹配会直接 reject。连续失败三次进入回滚逻辑,保留上一版可用资源,不覆盖。对于游戏来说,宁可让用户看到旧资源,也不能让他落在一个半新半旧的状态里,这比任何错误提示都致命。

6. 进阶:分包代替流式、自定义包头与完整性的三次校验

6.1 按需解压的真实做法:分包而不是流式

很多人想找“流式解压 zip”的库,我可以直接说结论:JSZip 不支持,zip.js 的流式也主要在浏览器端可用,原生和小游戏上要付出不少适配成本。真正的按需解压不是流式,而是拆包。把资源按功能域切片,每个包里只有自己那一组贴图和配置,客户端按运营位或活动 ID 只下载对应的包。这样单包体积降下来,内存问题、下载失败率、解压耗时全部一起缓解。服务端配合一个“包清单”接口,客户端拿到清单才知道该拉哪个 zip,这个架构比在客户端硬啃流式靠谱得多。

6.2 给 ZIP 套自定义头,兼容 CDN 与平台过滤

我给线上包做过一个保护措施:打包时在 zip 前面拼一段 4096 字节的自定义头,存魔数、原始长度和 CRC32。好处有两个:第一,下载完可以先验头再决定要不要把整包交给解压器;第二,有些安全组件和 CDN 会识别文件魔数做拦截,加了自定义头之后,文件不再是标准的 zip 头,能绕开部分“按魔数过滤”的策略。打包端用 Node 脚本实现:

const fs = require('fs'); const zlib = require('zlib'); function packWithHeader(zipPath, outPath) { const zipBuf = fs.readFileSync(zipPath); const header = Buffer.alloc(4096); // 固定 4096 字节,尾部零填充 header.write('CCZP', 0, 'ascii'); // 魔数,4 字节 header.writeUInt32BE(zipBuf.length, 8); // 原始 zip 长度 header.writeUInt32BE(zlib.crc32(zipBuf) >>> 0, 12); // 原始 zip 的 CRC32 fs.writeFileSync(outPath, Buffer.concat([header, zipBuf])); }

逻辑说明:header.writeUInt32BE(zipBuf.length, 8)把长度放在偏移 8 的位置,是自定义的布局,读取时也按这个偏移解析;CRC32 用 Node 自带的zlib.crc32,Node 版本低于 15 时要换第三方库。游戏端读取时先检查魔数,再用偏移 8 读长度、偏移 12 验 CRC,验过之后把 4096 字节裁掉,剩余部分交给JSZip.loadAsync。这个头本身也可以顺便带版本号、包 ID 和加密信息,但别把密钥写进包里,那只拦君子不拦贼。

6.3 把校验写进下载流程,三次失败回滚

最后形成一个固定习惯:下载完成先对长度,再查魔数和 EOCD,最后解压时让 JSZip 的 CRC32 校验兜底,三层校验全过才算成功。任何一步失败都不切换目录,尝试三次全部失分后回滚到旧版本。我早年做运营资源包更新时,翻过两次车:一次是忘了 EOCD 校验,CDN 截断的包直接上线,玩家全部卡在加载页;一次是 GBK 中文名没处理,活动图片资源全部 404。这两条后来都写进了打包脚本的自检逻辑里,打包时先跑一遍“虚拟解压”,发现问题直接在构建期爆出来,而不是让玩家在手机上报 bug。希望这些坑能让你少走一段弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询