├── .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 |
--------------------------------------------------------------------------------