├── .gitignore ├── README.md ├── pack ├── README.md ├── bin │ └── mini-program-pack.js ├── cli.js ├── index.d.ts ├── lib.js ├── package.json ├── test │ └── test.js └── ver.js └── unpack ├── README.md ├── index.d.ts ├── index.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | .bak 2 | *.br 3 | node_modules -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 小程序静态资源打包方案 2 | 3 | ## 解决问题 4 | 5 | 小程序/小游戏由于受到包体积限制,因此一些体积较大的本地资源可考虑以压缩的格式进行存储,尤其是压缩率高、解压快的 brotli 格式。 6 | 7 | 微信小程序现已提供原生解压 brotli 压缩文件的能力,但其他小程序目前不支持该 API,因此需要一个兼容方案。 8 | 9 | 此外,开发者有时希望将多个文件打包成一个资源包,方便使用并能提升压缩率,因此需要记录额外的文件信息。 10 | 11 | 本工具主要解决上述问题。 12 | 13 | ## 兼容方案 14 | 15 | 目前大部分小程序都支持加载 brotli 压缩后的 wasm 文件,因此开发者可将静态资源打包成一个只有数据段的 wasm 文件,并进行压缩;运行时直接加载 `.wasm.br` 文件,即可从导出对象的内存中读取原始数据。 16 | 17 | ## 文件打包 18 | 19 | 安装 [mini-program-pack](pack) 工具: 20 | 21 | ```bash 22 | npm i -g mini-program-pack 23 | ``` 24 | 25 | 演示: 26 | 27 | ```bash 28 | echo "Hello" > t1.txt 29 | echo "abc123" > t2.txt 30 | 31 | mini-program-pack --binary t1.txt t2.txt -o res.wasm.br 32 | ``` 33 | 34 | 将 `t1.txt` 和 `t2.txt` 以二进制格式打包压缩,生成 `res.wasm.br`。 35 | 36 | 该 wasm 文件不包含任何指令,仅用作数据载体而已。 37 | 38 | ## 文件读取 39 | 40 | 小程序项目中安装 [mini-program-unpack](unpack) 库: 41 | 42 | ```bash 43 | npm i mini-program-unpack 44 | ``` 45 | 46 | 运行: 47 | 48 | ```javascript 49 | import unpack from 'mini-program-unpack' 50 | 51 | unpack('res.wasm.br').then(pkg => { 52 | console.log(pkg.files) // ["t1.txt", "t2.txt"] 53 | console.log(pkg.read('t1.txt')) // Uint8Array(6) [72, 101, 108, 108, 111, 10] 54 | console.log(pkg.read('t2.txt')) // Uint8Array(7) [97, 98, 99, 49, 50, 51, 10] 55 | }) 56 | ``` 57 | 58 | 解压过程是后台异步执行的,不会阻塞主线程。 59 | 60 | 在支持原生 brotli 解压的环境中,程序不会调用 wasm 接口,而是直接解压 .wasm.br 文件,然后跳过 wasm 文件头,因此可快 10% 左右。 61 | 62 | ## 文本优化 63 | 64 | 由于小程序不支持 `TextDecoder` 等二进制转文本的 API,因此开发者只能自己实现 UTF-8 解码,这不仅需要额外的代码,而且性能很低。 65 | 66 | 为此本工具提供了文本模式,可大幅提升文本读取性能。打包时通过 `--text` 指定使用文本模式的文件: 67 | 68 | ```bash 69 | echo "Hello" > t1.txt 70 | echo "abc123" > t2.txt 71 | echo "你好😁" > t3.txt 72 | 73 | mini-program-pack --binary t1.txt --text t2.txt t3.txt -o res.wasm.br 74 | ``` 75 | 76 | 读取文本文件,返回的是 `string` 类型: 77 | 78 | ```javascript 79 | unpack('res.wasm.br').then(pkg => { 80 | console.log(pkg.files) // ["t1.txt", "t2.txt", "t3.txt"] 81 | console.log(pkg.read('t1.txt')) // Uint8Array(6) [72, 101, 108, 108, 111, 10] 82 | console.log(pkg.read('t2.txt')) // "abc123\n" 83 | console.log(pkg.read('t3.txt')) // "你好😁\n" 84 | }) 85 | ``` 86 | 87 | 对于单字节文本,例如 `ASCII`、`ISO-8859-1` 格式,使用文本模式不会降低压缩率。 88 | 89 | 对于多字节文本,例如含有汉字的内容,使用文本模式通常会损失 10%-20% 的压缩率,具体取决于汉字数量,汉字越多损失越少。 90 | 91 | 原因是单字节文本的字符以 u8[] 存储,而多字节文本的字符以 u16[] 存储,由于每个字符都占用 2 字节,导致体积膨胀,尽管压缩可去除冗余,但相比二进制模式仍有损失。 92 | 93 | 之所以直接存储字码,是因为读取时可通过 `String.fromCharCode.apply` 批量解码,相比逐字处理可以快几十倍。 94 | 95 |
96 | 性能测试:批量解码 vs 逐字处理 97 | 98 | ```javascript 99 | const testData = new Uint16Array(1024 * 1024 * 8) 100 | for (let i = 0; i < testData.length; i++) { 101 | testData[i] = i 102 | } 103 | const chr = String.fromCharCode 104 | let strApply = '' 105 | let strLoop = '' 106 | 107 | const t0 = Date.now() 108 | 109 | for (let i = 0; i < testData.length; i += 32768) { 110 | const part = testData.subarray(i, i + 32768) 111 | strApply += chr.apply(0, part) 112 | } 113 | const t1 = Date.now() 114 | 115 | for (let i = 0; i < testData.length; i++) { 116 | strLoop += chr(testData[i]) 117 | } 118 | const t2 = Date.now() 119 | 120 | console.log('apply time:', t1 - t0) 121 | console.log('loop time:', t2 - t1) 122 | console.log(strLoop === strApply) 123 | ``` 124 |
125 | 126 | https://jsbin.com/mofapad/edit?html,console 127 | 128 | ## 兼容性 129 | 130 | * 微信小程序 v2.14.0 131 | 132 | https://developers.weixin.qq.com/miniprogram/dev/framework/performance/wasm.html 133 | 134 | 微信小程序 v2.21.1 支持原生 br 解压 135 | 136 | https://developers.weixin.qq.com/miniprogram/dev/api/file/FileSystemManager.readCompressedFile.html 137 | 138 | 由于 Worker 环境访问不到 wx 对象,因此无法原生 br 解压。但能访问 WXWebAssembly 对象(v2.15.0),仍可使用 .wasm.br 方案。 139 | 140 | * 抖音小程序 v2.92.0.0 141 | 142 | https://developer.open-douyin.com/docs/resource/zh-CN/mini-app/develop/guide/experience-optimization/list/wasm 143 | 144 | * 支付宝小程序 145 | 146 | https://opendocs.alipay.com/mini/0b2bz8 147 | -------------------------------------------------------------------------------- /pack/README.md: -------------------------------------------------------------------------------- 1 | # Mini Program Pack 2 | 3 | ## 简介 4 | 5 | 小程序静态资源打包和压缩方案:https://github.com/EtherDream/mini-program-pack 6 | 7 | ## 命令行 8 | 9 | 安装工具: 10 | 11 | ```bash 12 | npm i -g mini-program-pack 13 | ``` 14 | 15 | 用法参考: 16 | 17 | ```bash 18 | mini-program-pack \ 19 | -b *.bin \ 20 | -t *.txt \ 21 | -p assets \ 22 | -o ~/WeChatProjects/test-project/res.wasm.br 23 | ``` 24 | 25 | 参数说明: 26 | 27 | * -b, --binary: 输入的二进制文件列表。 28 | 29 | * -t, --text:输入的文本文件列表。如果和二进制文件重复,则优先使用文本文件。 30 | 31 | * -o, --output:输出的包文件,以 `.wasm.br` 结尾。 32 | 33 | * -p, --path:指定输入文件所在的目录,默认为当前目录。该部分不会被记录到包中。 34 | 35 | 输入文件必须位于 `--path` 目录下。目录分隔符可使用 `/` 或 `\`,程序会统一转换成 `/`。 36 | 37 | ## API 调用 38 | 39 | ```javascript 40 | import zlib from 'node:zlib' 41 | import pack from 'mini-program-pack' 42 | 43 | const txtFileMap = { 44 | '1.txt': 'Hello World', 45 | '2.txt': '你好😁', 46 | } 47 | const binFileMap = { 48 | 'foo/bar.txt': Buffer.from([10, 11, 12, 13, 14, 15]), 49 | } 50 | 51 | const wasmBuf = pack(binFileMap, txtFileMap) 52 | const brBuf = zlib.brotliCompressSync(wasmBuf) 53 | // ... 54 | ``` 55 | 56 | ## 包文件结构 57 | 58 | 排除 wasm 头之后的内容: 59 | 60 | ```c 61 | struct { 62 | // 文件数量 63 | u32 fileNum 64 | 65 | // 文件属性 66 | struct { 67 | u2 type { BINARY = 0, ISO_8859_1 = 1, UTF_16 = 2 } 68 | u30 size 69 | } attrs[fileNum + 1] 70 | 71 | // 第 1 个文件数据 72 | u8 data0[ attrs[0].size ] 73 | 74 | // 第 2 个文件数据 75 | u8 data1[ attrs[1].size ] 76 | ... 77 | // 第 fileNum 个文件数据 78 | u8 dataN[ attrs[fileNum - 1].size ] 79 | 80 | // 存储文件名的虚拟文件 81 | u8 fileNameText[ attrs[fileNum].size ] 82 | 83 | // 整个结构体的长度(原生解压 br 时可直接从尾部读取,无需分析 wasm 文件头) 84 | u32 packageLen 85 | } 86 | ``` 87 | 88 | 每个文件数据在底层内存块(ArrayBuffer)中的偏移都对齐到 8 的整数倍,方便开发者可用各种类型的 TypedArray 直接映射到数据上: 89 | 90 | ```js 91 | bytes = pkg.read('u16-array.bin') 92 | arr16 = new Uint16Array(bytes.buffer, bytes.byteOffset, bytes.length / 2) 93 | 94 | bytes = pkg.read('u32-array.bin') 95 | arr32 = new Uint32Array(bytes.buffer, bytes.byteOffset, bytes.length / 4) 96 | 97 | bytes = pkg.read('u64-array.bin') 98 | arr64 = new BigUint64Array(bytes.buffer, bytes.byteOffset, bytes.length / 8) 99 | ``` 100 | 101 | 如果文件数据是固定类型的数组,那么映射后即可直接访问,而无需复制内存或通过 DataView,从而提升性能。 102 | -------------------------------------------------------------------------------- /pack/bin/mini-program-pack.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import '../cli.js' -------------------------------------------------------------------------------- /pack/cli.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import zlib from 'node:zlib' 3 | import {resolve} from 'node:path' 4 | import {Command} from 'commander' 5 | import pack from './lib.js' 6 | import ver from './ver.js' 7 | 8 | 9 | function formatNum(num) { 10 | return num.toLocaleString() 11 | } 12 | 13 | function main(args) { 14 | const baseDir = resolve(args.path) 15 | 16 | const toAbsolutePath = (path) => { 17 | return resolve(baseDir, path) 18 | } 19 | const binFilePathArr = (args.binary || []).map(toAbsolutePath) 20 | const txtFilePathArr = (args.text || []).map(toAbsolutePath) 21 | 22 | const binFilePathSet = new Set(binFilePathArr) 23 | const txtFilePathSet = new Set(txtFilePathArr) 24 | 25 | for (const path of txtFilePathSet) { 26 | if (binFilePathSet.delete(path)) { 27 | console.warn(path, '已切换到文本模式') 28 | } 29 | } 30 | const fileNum = binFilePathSet.size + txtFilePathSet.size 31 | if (fileNum === 0) { 32 | console.error('未指定输入文件') 33 | return 34 | } 35 | if (!args.output) { 36 | console.error('未指定输出文件') 37 | return 38 | } 39 | if (!args.output.endsWith('.wasm.br')) { 40 | console.warn('目标文件扩展名不是 .wasm.br') 41 | } 42 | 43 | console.log('根目录:', baseDir) 44 | 45 | const addFiles = (filePathSet, isBin) => { 46 | const map = {} 47 | 48 | for (const absPath of filePathSet) { 49 | if (!absPath.startsWith(baseDir)) { 50 | throw Error('文件 ' + absPath + ' 不在根目录下') 51 | } 52 | const relPath = absPath 53 | .replace(baseDir, '') // 去除根目录前缀 54 | .substring(1) // 去除斜杠前缀 55 | .replace(/\\/g, '/') // 目录分隔符统一使用 `/` 56 | 57 | const binData = fs.readFileSync(absPath) 58 | map[relPath] = isBin ? binData : binData.toString() 59 | 60 | console.log(isBin ? '[bin]' : '[txt]', 61 | relPath, '(' + formatNum(binData.length) + ' 字节)' 62 | ) 63 | } 64 | return map 65 | } 66 | 67 | const binFileMap = addFiles(binFilePathSet, true) 68 | const txtFileMap = addFiles(txtFilePathSet, false) 69 | 70 | const wasmBuf = pack(binFileMap, txtFileMap) 71 | 72 | console.log('压缩中... (' + formatNum(wasmBuf.length) + ' 字节)') 73 | 74 | const brBuf = zlib.brotliCompressSync(wasmBuf, { 75 | params: { 76 | [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY, 77 | } 78 | }) 79 | fs.writeFileSync(args.output, brBuf) 80 | 81 | const ratioNum = brBuf.length / wasmBuf.length 82 | const ratioStr = (ratioNum * 100).toFixed(2) + '%' 83 | 84 | console.log('保存到', args.output, 85 | '(' + formatNum(brBuf.length) + ' 字节, 压缩率: ' + ratioStr + ')' 86 | ) 87 | } 88 | 89 | 90 | new Command() 91 | .option('-b, --binary ', '二进制文件') 92 | .option('-t, --text ', '文本文件') 93 | .option('-o, --output ', '生成的包文件') 94 | .option('-p, --path ', '原文件所在的目录', '.') 95 | .action(args => { 96 | try { 97 | main(args) 98 | } catch (err) { 99 | console.error(err.message) 100 | } 101 | }) 102 | .version(ver) 103 | .parse(process.argv) 104 | -------------------------------------------------------------------------------- /pack/index.d.ts: -------------------------------------------------------------------------------- 1 | export default function( 2 | binFileMap: { 3 | [path: string]: Uint8Array 4 | }, 5 | txtFileMap: { 6 | [path: string]: string 7 | } 8 | ) : Uint8Array 9 | -------------------------------------------------------------------------------- /pack/lib.js: -------------------------------------------------------------------------------- 1 | function uleb128(num) { 2 | const bytes = [] 3 | let pos = 0 4 | 5 | for (;;) { 6 | const value = num & 0x7F 7 | num >>>= 7 8 | if (num === 0) { 9 | bytes[pos] = value 10 | return bytes 11 | } 12 | bytes[pos++] = value | 0x80 13 | } 14 | } 15 | 16 | function section(code, bytes, remainLen = 0) { 17 | return [ 18 | code, 19 | ...uleb128(bytes.length + remainLen), 20 | ...bytes, 21 | ] 22 | } 23 | 24 | function genWasmHead(dataLen) { 25 | // WebAssembly 以页为单位分配内存,每页 64kB 26 | const pageNum = Math.ceil(dataLen / 65536) 27 | 28 | // 通过调整导出名的长度,可将 wasm 文件头长度凑到 8 的整数倍 29 | let nameLen = 0 30 | 31 | for (;;) { 32 | const nameBuf = Buffer.alloc(nameLen, 'a') 33 | 34 | const wasmHead = Buffer.from([ 35 | 0x00, 0x61, 0x73, 0x6D, // magic 36 | 0x01, 0x00, 0x00, 0x00, // version 37 | 38 | ...section(0x05, [ // [Linear-Memory Section] 39 | 0x01, // num memories 40 | 0x00, // limits: flags (initial only) 41 | ...uleb128(pageNum), // limits: initial 42 | ]), 43 | 44 | ...section(0x07, [ // [Export Section] 45 | 0x01, // num exports 46 | nameLen, // string length 47 | ...nameBuf, // export name 48 | 0x02, // export kind (Memory) 49 | 0x00, // export memory index 50 | ]), 51 | 52 | ...section(0x0B, [ // [Data Section] 53 | 0x01, // num data segments 54 | 0x00, // segment flags 55 | 0x41, // i32.const 56 | 0x00, // i32 literal 57 | 0x0B, // end 58 | ...uleb128(dataLen), // data segment size 59 | ], dataLen), 60 | ]) 61 | 62 | if (wasmHead.length % 8 === 0) { 63 | return wasmHead 64 | } 65 | nameLen++ 66 | } 67 | } 68 | 69 | const TYPE_BINARY = 0 70 | const TYPE_ISO_8859_1 = 1 71 | const TPYE_UTF_16 = 2 72 | 73 | 74 | export default function(binFileMap, txtFileMap) { 75 | const fileNames = [ 76 | ...Object.keys(binFileMap), 77 | ...Object.keys(txtFileMap), 78 | ] 79 | const fileNum = fileNames.length 80 | 81 | // 末尾添加一个虚拟文件,用于存储文件名(该文件没有名字,也不算在 fileNum 中) 82 | const head = new Uint32Array(1 + fileNum + 1) 83 | let headPos = 0 84 | 85 | // 文件个数 86 | head[headPos++] = fileNum 87 | 88 | const pkgBufs = [ 89 | Buffer.from(head.buffer), 90 | ] 91 | let pkgLen = head.byteLength 92 | 93 | const align = (n) => { 94 | if (pkgLen % n) { 95 | const padding = Buffer.alloc(n - pkgLen % n) 96 | pkgBufs.push(padding) 97 | pkgLen += padding.length 98 | } 99 | } 100 | 101 | const addFile = (type, buf) => { 102 | // 数据起始位置对齐到 8 的整数倍 103 | align(8) 104 | pkgBufs.push(buf) 105 | pkgLen += buf.length 106 | 107 | const attr = type << 30 | buf.length 108 | head[headPos++] = attr 109 | } 110 | 111 | 112 | for (const bin of Object.values(binFileMap)) { 113 | if (!(bin instanceof Uint8Array)) { 114 | throw TypeError('invalid binary type') 115 | } 116 | addFile(TYPE_BINARY, bin) 117 | } 118 | 119 | fileNames.forEach(s => { 120 | if (s.includes('\n')) { 121 | throw Error('invalid file name:' + s) 122 | } 123 | }) 124 | const fileNameText = fileNames.join('\n') 125 | const texts = Object.values(txtFileMap).concat(fileNameText) 126 | 127 | for (const txt of texts) { 128 | if (typeof txt !== 'string') { 129 | throw TypeError('invalid string type') 130 | } 131 | const codes = new Uint16Array(txt.length) 132 | let singleByte = true 133 | 134 | for (let i = 0; i < txt.length; i++) { 135 | codes[i] = txt.charCodeAt(i) 136 | if (codes[i] > 255) { 137 | singleByte = false 138 | } 139 | } 140 | if (singleByte) { 141 | addFile(TYPE_ISO_8859_1, Buffer.from(codes)) 142 | } else { 143 | addFile(TPYE_UTF_16, Buffer.from(codes.buffer)) 144 | } 145 | } 146 | 147 | // 末尾追加 4 字节用于存储数据长度,方便原生解压 br 的场合读取 148 | //(直接从尾部读取长度和数据,无需解析 wasm 头) 149 | align(4) 150 | pkgLen += 4 151 | 152 | const wasmHead = genWasmHead(pkgLen) 153 | 154 | const wasmFile = Buffer.concat([ 155 | wasmHead, 156 | ...pkgBufs, 157 | Buffer.from(Uint32Array.of(pkgLen).buffer), 158 | ]) 159 | return wasmFile 160 | } -------------------------------------------------------------------------------- /pack/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mini-program-pack", 3 | "version": "1.0.12", 4 | "description": "小程序静态资源打包工具", 5 | "bin": { 6 | "mini-program-pack": "bin/mini-program-pack.js" 7 | }, 8 | "dependencies": { 9 | "commander": "^11.1.0" 10 | }, 11 | "files": [ 12 | "*.js", 13 | "index.d.ts" 14 | ], 15 | "main": "lib.js", 16 | "types": "index.d.ts", 17 | "scripts": { 18 | "test": "node test/test.js", 19 | "prepack": "echo \"export default '$npm_package_version'\" > ver.js" 20 | }, 21 | "type": "module", 22 | "homepage": "https://github.com/EtherDream/mini-program-pack/pack", 23 | "keywords": [ 24 | "小程序", 25 | "小游戏", 26 | "资源打包", 27 | "UTF8解码", 28 | "br压缩", 29 | "brotli", 30 | "WebAssembly" 31 | ], 32 | "author": "EtherDream", 33 | "license": "MIT" 34 | } 35 | -------------------------------------------------------------------------------- /pack/test/test.js: -------------------------------------------------------------------------------- 1 | import crypto from 'node:crypto' 2 | import pack from '../lib.js' 3 | import unpack from '../../unpack/index.js' 4 | 5 | 6 | function assert(exp, ...msg) { 7 | if (!exp) { 8 | console.error('assert:', ...msg) 9 | debugger 10 | process.exit(1) 11 | } 12 | } 13 | 14 | // 15 | // 生成数据 16 | // 17 | const txtFileMap = { 18 | '': '', 19 | 'empty.txt': '', 20 | 'big.txt': '\x01'.repeat(1024 * 1024 * 4), 21 | ['long-key-txt'.repeat(1024 * 128)]: '\xFF', 22 | } 23 | 24 | let str = '' 25 | for (let i = 0; i < 655360; i++) { 26 | str += String.fromCodePoint(i) 27 | } 28 | txtFileMap['unicode.txt'] = str 29 | 30 | for (let i = 0; i < 65536; i++) { 31 | if (i !== 10) { 32 | const ch = String.fromCharCode(i) 33 | txtFileMap[ch + '.txt'] = i + ': ' + ch 34 | } 35 | } 36 | 37 | const binFileMap = { 38 | 'empty-bin': Buffer.alloc(0), 39 | 'big-bin': Buffer.alloc(1024 * 1024 * 4), 40 | ['long-key-bin'.repeat(1024)]: Buffer.from([0, 1, 2, 3]), 41 | } 42 | 43 | for (let i = 0; i < 65536; i++) { 44 | if (i !== 10) { 45 | const ch = String.fromCharCode(i) 46 | binFileMap[ch + '.bin'] = crypto.randomBytes(Math.random() * 16) 47 | } 48 | } 49 | 50 | // 51 | // 验证数据 52 | // 53 | const wasmBrBuf = pack(binFileMap, txtFileMap) 54 | const pkg = await unpack(wasmBrBuf) 55 | const {files} = pkg 56 | 57 | for (const [k, v] of Object.entries(txtFileMap)) { 58 | assert(files.includes(k), '[txt] pkg.files') 59 | assert(pkg.has(k), '[txt] pkg.has') 60 | 61 | assert(pkg.read(k) === v, '[txt] pkg.read') 62 | } 63 | 64 | for (const [k, v] of Object.entries(binFileMap)) { 65 | assert(files.includes(k), '[bin] pkg.files') 66 | assert(pkg.has(k), '[bin] pkg.has') 67 | 68 | const got = pkg.read(k) 69 | assert(v.equals(got)) 70 | assert(got.byteOffset % 8 === 0) 71 | } 72 | 73 | assert(pkg.has('no-such-file') === false, 'no-such-file') 74 | 75 | console.log('done') -------------------------------------------------------------------------------- /pack/ver.js: -------------------------------------------------------------------------------- 1 | export default '1.0.12' 2 | -------------------------------------------------------------------------------- /unpack/README.md: -------------------------------------------------------------------------------- 1 | # Mini Program Unpack 2 | 3 | ## 简介 4 | 5 | 小程序静态资源打包方案:https://github.com/EtherDream/mini-program-pack/ 6 | 7 | ## 安装 8 | 9 | ```bash 10 | npm i mini-program-unpack 11 | ``` 12 | 13 | ## API 文档 14 | 15 | 参考:[index.d.ts](index.d.ts) 16 | -------------------------------------------------------------------------------- /unpack/index.d.ts: -------------------------------------------------------------------------------- 1 | declare class MiniPackage { 2 | /** 3 | * 读取文件数据 4 | */ 5 | read(fileName: string) : Uint8Array | string | undefined 6 | 7 | /** 8 | * 获取文件名列表 9 | */ 10 | get files() : string[] 11 | 12 | /** 13 | * 判断文件是否存在 14 | */ 15 | has(fileName: string) : boolean 16 | } 17 | 18 | /** 19 | * 小程序中只能传入 wasm 文件路径 20 | * 浏览器或 Node.js 中可传入 wasm 文件内容 21 | */ 22 | export default function(wasmBr: string | BufferSource) : Promise 23 | -------------------------------------------------------------------------------- /unpack/index.js: -------------------------------------------------------------------------------- 1 | const TYPE_BINARY = 0 2 | const TYPE_ISO_8859_1 = 1 3 | const TPYE_UTF_16 = 2 4 | 5 | let maxStackLen = 32768 6 | 7 | 8 | function readText(bin, type, offset, size) { 9 | const codes = (type === TYPE_ISO_8859_1) 10 | ? bin.subarray(offset, offset + size) 11 | : new Uint16Array(bin.buffer, bin.byteOffset + offset, size / 2) 12 | 13 | do { 14 | try { 15 | let str = '' 16 | for (let i = 0; i < size; i += maxStackLen) { 17 | const part = codes.subarray(i, i + maxStackLen) 18 | str += String.fromCharCode.apply(0, part) 19 | } 20 | return str 21 | } catch (err) { 22 | maxStackLen = (maxStackLen * 0.8) | 0 23 | } 24 | } while (maxStackLen) 25 | } 26 | 27 | 28 | class MiniPackage { 29 | constructor(map, bin) { 30 | this._map = map 31 | this._bin = bin 32 | } 33 | 34 | read(fileName) { 35 | const fileInfo = this._map.get(fileName) 36 | if (!fileInfo) { 37 | return 38 | } 39 | const [type, size, offset] = fileInfo 40 | if (type === TYPE_BINARY) { 41 | return this._bin.subarray(offset, offset + size) 42 | } 43 | return readText(this._bin, type, offset, size) 44 | } 45 | 46 | get files() { 47 | return Array.from(this._map.keys()) 48 | } 49 | 50 | has(fileName) { 51 | return this._map.has(fileName) 52 | } 53 | } 54 | 55 | 56 | function unpack(ptrU32, ptrU8) { 57 | const fileNum = ptrU32[0] 58 | let offset = (1 + fileNum + 1) * 4 59 | 60 | const fileInfos = [] 61 | let fileNames 62 | 63 | for (let i = 0;;) { 64 | const attr = ptrU32[1 + i] 65 | const type = attr >>> 30 66 | const size = attr << 2 >>> 2 67 | 68 | // 对齐到 8 的整数倍 69 | offset = (offset + 7) & -8 70 | 71 | // 最后一个为虚拟文件,用于存储文件名 72 | if (i === fileNum) { 73 | const fileNameText = readText(ptrU8, type, offset, size) 74 | fileNames = fileNameText.split('\n') 75 | break 76 | } 77 | fileInfos[i] = [type, size, offset] 78 | offset += size 79 | i++ 80 | } 81 | 82 | const entries = fileNames.map((fileName, i) => { 83 | return [fileName, fileInfos[i]] 84 | }) 85 | const map = new Map(entries) 86 | return new MiniPackage(map, ptrU8) 87 | } 88 | 89 | 90 | function getNativeDecoder() { 91 | const native = 92 | typeof wx === 'object' ? wx : 93 | typeof my === 'object' ? my : 94 | typeof tt === 'object' ? tt : 95 | 0 96 | if (!native) { 97 | return 98 | } 99 | const fs = native.getFileSystemManager() || {} 100 | const {readCompressedFile} = fs 101 | if (!readCompressedFile) { 102 | return 103 | } 104 | return (wasmBrPath) => new Promise((resolve, reject) => { 105 | readCompressedFile({ 106 | filePath: wasmBrPath, 107 | compressionAlgorithm: 'br', 108 | success(res) { 109 | const buffer = res.data 110 | const u32 = new Uint32Array(buffer) 111 | const dataLen = u32[u32.length - 1] 112 | 113 | const offset = buffer.byteLength - dataLen 114 | const ptrU32 = new Uint32Array(buffer, offset, dataLen / 4) 115 | const ptrU8 = new Uint8Array(buffer, offset, dataLen) 116 | const pkg = unpack(ptrU32, ptrU8) 117 | resolve(pkg) 118 | }, 119 | fail(res) { 120 | reject(res.errMsg) 121 | } 122 | }) 123 | }) 124 | } 125 | 126 | function getMiniWasmDecoder() { 127 | const wasm = 128 | typeof WXWebAssembly === 'object' ? WXWebAssembly : 129 | typeof TTWebAssembly === 'object' ? TTWebAssembly : 130 | typeof MYWebAssembly === 'object' ? MYWebAssembly : 131 | typeof WebAssembly === 'object' ? WebAssembly : 132 | 0 133 | if (!wasm) { 134 | throw Error('WebAssembly is not supported') 135 | } 136 | return (wasmBr) => wasm.instantiate(wasmBr).then(r => { 137 | const {buffer} = Object.values(r.instance.exports)[0] 138 | const ptrU32 = new Uint32Array(buffer) 139 | const ptrU8 = new Uint8Array(buffer) 140 | return unpack(ptrU32, ptrU8) 141 | }) 142 | } 143 | 144 | export default getNativeDecoder() || getMiniWasmDecoder() 145 | -------------------------------------------------------------------------------- /unpack/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mini-program-unpack", 3 | "version": "1.0.0", 4 | "description": "小程序静态资源解包库", 5 | "main": "index.js", 6 | "type": "module", 7 | "types": "index.d.ts", 8 | "homepage": "https://github.com/EtherDream/mini-program-pack/unpack", 9 | "keywords": [ 10 | "小程序", 11 | "小游戏", 12 | "资源打包", 13 | "UTF8解码", 14 | "br压缩", 15 | "brotli", 16 | "WebAssembly" 17 | ], 18 | "author": "EtherDream", 19 | "license": "MIT" 20 | } 21 | --------------------------------------------------------------------------------