我是 Moushu,分享一个近期在工作中遇到的问题,我在互联网上搜了一圈没有看到相关解决方案,所以写了一篇掘金,分享给大家
有帮到的话请点个赞吧,或者关注我的 GitHub
背景
由于一些后端的问题,我拿到了一个需要传递 FormData 的上传文件接口,里面有多个字段,其中一个是 Blob 里面塞文件流
尝试
对于一名高强度摸鱼工作数年的资深前端来说,这种基本功一样的东西应该是手到擒来
我迅速命令AI编写了以下代码:
// 我知道这种多级嵌套的代码很难看, 但是公司给的工资也不好看啊
// 再说了, 这个多级结构是 AI 生成的, 我只负责核心部分
// 代码 review? 跟我的 vibe coding 说去吧!
async function handleUpload() {
const res = await Taro.chooseImage({ count: 1 })
const file = res.tempFiles[0]
if (!file) return
try {
Taro.showLoading({ title: '上传中...' })
const fs = Taro.getFileSystemManager()
fs.readFile({
filePath: file.path,
success: async ({ data }) => {
try {
const formData = new FormData()
// 核心部分
// 这里省略 append 与访问接口的代码
// 最终调用的是一个封装了 axios 的 $http.post 方法(主要封装内容是自动添加 token + 内容加密)
} catch (err) {} finally {
Taro.hideLoading()
}
},
fail: (err) => {}
})
} catch (err) {}
}
经过简单尝试,我发现这个完美的代码无法运行
原因很简单:微信小程序环境没有 FormData 也没有 Blob
女娲补天计划之放弃补天
经过简单查看 FormData 和 Blob 的标准文档后,我放弃了女娲补天的计划——实现一个标准的 FormData 和 Blob 太麻烦了
更何况公司给的这点钱,我 8 小时的上班时间没有摸掉 7 小时已经很给面子了
所以我决定投机取巧:
- 微信小程序环境支持
ArrayBuffer,刚好这个类型的数据可以塞进body里面提交 - 虽然不能直接实现一个
FormData和Blob但是构造一个符合FormData标准的ArrayBuffer并不是难事
一个符合 FormData 标准的 ArrayBuffer 结构如下:
------这里是边界boundary任何内部的信息都不能与边界字符串重复
Content-Disposition: form-data; name="字段名1"
字段名1的具体内容
------这里是边界boundary任何内部的信息都不能与边界字符串重复
Content-Disposition: form-data; name="字段名2"; filename="图片.png"
Content-Type: image/png或者别的什么MIME类型
这里是图片.png的二进制数据流
------这里是边界boundary任何内部的信息都不能与边界字符串重复--
其中 boundary 除了 body 里用到之外,请求头也要用到
那么答案就很简单了:
- 生成一个边界字符串
boundary - 根据标准文档,构建字符串,然后用
TextEncoder处理为Uint8Array然后获取ArrayBuffer - 把这个
ArrayBuffer塞进body里,设置头信息,然后——万事大吉,下班收工
这不是投机取巧,是最小可运行示例
根据上一节讲述的思路,我需要编写一个方法,将一个对象 Record<string, string | Blob> 处理为一个 ArrayBuffer,这个方法需要能够自定义 boundary,生成请求头的时候要用
为此,我编写了这么一个东西:
// 我知道我的代码很烂, 将就着看
// 关键节点的注释都标记好了, 确保哪怕你看不懂, 你的组长/技术经理也能看得懂
// 如果你的组长/技术经理都看不懂(或者你就是那个组长/技术经理), 我建议你买块豆腐拍死他们(或者拍死自己)
// ... 或者把这篇掘金文档丢给 AI, AI 看得懂(顺便思考一下自己是不是要丢工作了)
// 至于 Blob, 我用这个替代: { type: 'image/png', name: 'image.png', arrayBuffer: async () => data }
const encoder = new TextEncoder()
async function createFormData(params = {}, boundary = '') {
const chunks: Uint8Array[] = []
for (let i in params) {
// 构建 Header 部分
let headerStr = `--${boundary}\r\n`
headerStr += `Content-Disposition: form-data; name="${i}"`
const item = (params as Record<string, any>)[i]
if (typeof item !== 'object' || !item || typeof item.arrayBuffer !== 'function') {
// 普通文本字段
headerStr += `\r\n\r\n` // Header 和 Body 之间用空行分隔
chunks.push(encoder.encode(headerStr))
chunks.push(encoder.encode(String(item ?? ''))) // Body 内容
} else {
// Blob/文件字段
// 补充 filename 和 Content-Type
// 这里假设 item 是 Blob 对象, 如果是小程序临时文件, 需要先 readFileSystem 变成 ArrayBuffer
const filename = (item as any).name || 'blob'
const type = item.type || 'application/octet-stream'
headerStr += `; filename="${filename}"`
headerStr += `\r\nContent-Type: ${type}`
headerStr += `\r\n\r\n` // Header 和 Body 之间用空行分隔
console.log(i, 'headerStr:')
console.log(headerStr)
chunks.push(encoder.encode(headerStr))
// Blob.arrayBuffer() 是异步的,所以函数必须是 async
const buffer = await item.arrayBuffer()
chunks.push(new Uint8Array(buffer))
}
// 字段结束后,追加一个 CRLF
chunks.push(encoder.encode('\r\n'))
}
// 构建结束边界
if (params && Object.keys(params).length > 0) {
chunks.push(encoder.encode(`--${boundary}--\r\n`))
}
// 合并所有 Uint8Array 成一个完整的 ArrayBuffer
// 计算总长度
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0)
// 创建一个新的容器
const result = new Uint8Array(totalLength)
// 填入数据
let offset = 0
for (const chunk of chunks) {
result.set(chunk, offset)
offset += chunk.length
}
// 返回 ArrayBuffer, 直接用于 wx.request / Taro.request 的 data 字段
return result.buffer
}
考虑到公司已经几年不涨工资了,我懒得优化代码,只是将最开始的代码修改成这样:
async function handleUpload() {
const res = await Taro.chooseImage({ count: 1 })
const file = res.tempFiles[0]
if (!file) return
try {
Taro.showLoading({ title: '上传中...' })
const fs = Taro.getFileSystemManager()
fs.readFile({
filePath: file.path,
success: async ({ data }) => {
try {
const boundary = `----MoushuSuperBoundary${Date.now()}`
const contentType = `multipart/form-data; boundary=${boundary}`
const rawData = {
headId: 'xxx',
// 微信小程序没有 Blob 所以我用这个奇特的写法替代
attachment: { type: 'image/png', name: file.name || 'image.png', arrayBuffer: async () => data }
}
const formData = await createFormData(rawData, boundary)
// 后续代码略, 最终调用的是一个封装了 axios 的 $http.post 方法
} catch (err) {} finally {
Taro.hideLoading()
}
},
fail: (err) => {}
})
} catch (err) {}
}
保存,然后在微信开发者工具中测试,没问题,提代码,继续摸鱼研究部门前端的 AI 提效方案
新问题:页面打不开了
是的,以上的代码放进去,页面炸了,经过简单的定位,我们发现问题所在
天煞的微信小程序,开发者工具里有 TextEncoder 然后真机上没有!
我紧急看了一圈问题,发现要解决别无他法只能自行实现一个 TextEncoder(或者打后端一顿,让他重写这个接口以及这个接口的相关的成吨逻辑;我确实尝试了,但他拒绝了我的决斗邀请,并表示由于我在上海他在西安,物理规律决定了这场决斗是不可能的)
难道最后还是要女娲补天吗?不,我不接受,我的工资不支持我干这么复杂的事情!
经过简单复盘,我发现,其实也不用完整实现一个 TextEncoder,我只要一个 encoder.encode 方法即可
后面,我对照了一下 TextEncoder 和 UTF-8 的文档,从 GitHub 抄一个 TextEncoder 的实现废话我总不能自己写一个吧那也太麻烦了,并将无用的部分全部去除,得到了这样一个东西:
interface TextEncoderPolyfill {
new (): { encode(input: string): Uint8Array }
}
// 这个方法的代码看不懂没关系的, 我也不太看得懂, AI 看得懂就行
// 需要说明一下 UTF-8 的模式: 把码点切成若干 6-bit 段,按前缀模板拼起来:
// - 1 字节:0xxxxxxx
// - 2 字节:110xxxxx 10xxxxxx
// - 3 字节:1110xxxx 10xxxxxx 10xxxxxx
// - 4 字节:11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
// 这个 encoder 返回的是 Uint8Array, 以确保和标准的 encoder 相似
function getTextEncoderPolyfill(window: typeof globalThis) {
'use strict'
const NativeUint8Array = window.Uint8Array
const patchedU8Array = NativeUint8Array || Array
function TextEncoder() {}
// const TextEncoderPrototype = TextEncoder['prototype']
TextEncoder['prototype']['encode'] = function (inputString?: string) {
// 0xc0 => 0b11000000; 0xff => 0b11111111; 0xc0-0xff => 0b11xxxxxx
// 0x80 => 0b10000000; 0xbf => 0b10111111; 0x80-0xbf => 0b10xxxxxx
let encodedString = inputString === void 0 ? '' : '' + inputString
const len = encodedString.length | 0
let result = new patchedU8Array(((len << 1) + 8) | 0)
let tmpResult
let i = 0
let pos = 0
let point = 0
let nextCode = 0
let upgradedArraySize = !NativeUint8Array // 普通数组会自动扩展, 但 Uint8Array 不会, 需手动扩展
for (i = 0; i < len; i = (i + 1) | 0, pos = (pos + 1) | 0) {
point = encodedString.charCodeAt(i) | 0
if (point <= 0x007f) {
result[pos] = point
} else if (point <= 0x07ff) {
result[pos] = (0x6 << 5) | (point >> 6)
result[(pos = (pos + 1) | 0)] = (0x2 << 6) | (point & 0x3f)
} else {
widenCheck: {
if (0xd800 <= point) {
if (point <= 0xdbff) {
nextCode = encodedString.charCodeAt((i = (i + 1) | 0)) | 0 // NaN 默认置为 0, 生成 null 替代字符
if (0xdc00 <= nextCode && nextCode <= 0xdfff) {
// point = ((point - 0xD800)<<10) + nextCode - 0xDC00 + 0x10000|0;
point = ((point << 10) + nextCode - 0x35fdc00) | 0
if (point > 0xffff) {
result[pos] = (0x1e /*0b11110*/ << 3) | (point >> 18)
result[(pos = (pos + 1) | 0)] =
(0x2 /*0b10*/ << 6) | ((point >> 12) & 0x3f) /*0b00111111*/
result[(pos = (pos + 1) | 0)] =
(0x2 /*0b10*/ << 6) | ((point >> 6) & 0x3f) /*0b00111111*/
result[(pos = (pos + 1) | 0)] =
(0x2 /*0b10*/ << 6) | (point & 0x3f) /*0b00111111*/
continue
}
break widenCheck
}
point = 65533 // 0b1111111111111101 或者说 \xEF\xBF\xBD
} else if (point <= 0xdfff) {
point = 65533 // 0b1111111111111101 或者说 \xEF\xBF\xBD
}
}
if (!upgradedArraySize && i << 1 < pos && i << 1 < ((pos - 7) | 0)) {
upgradedArraySize = true
tmpResult = new patchedU8Array(len * 3)
tmpResult.set(result)
result = tmpResult
}
}
result[pos] = (0xe /*0b1110*/ << 4) | (point >> 12)
result[(pos = (pos + 1) | 0)] = (0x2 /*0b10*/ << 6) | ((point >> 6) & 0x3f) /*0b00111111*/
result[(pos = (pos + 1) | 0)] = (0x2 /*0b10*/ << 6) | (point & 0x3f) /*0b00111111*/
}
}
return NativeUint8Array ? result.subarray(0, pos) : result.slice(0, pos)
}
return { TextEncoder: TextEncoder as unknown as TextEncoderPolyfill }
}
const { TextEncoder } = getTextEncoderPolyfill(window)
const encoder = new TextEncoder()
最终成品
这么一个小小的功能,在微信小程序的限制下,愣是花了我好几个小时的时间,中间还出了页面崩溃的问题,不过最终结果是好的
最终代码的结构大概是:
// getTextEncoderPolyfill 代码见上文
const { TextEncoder } = getTextEncoderPolyfill(window)
const encoder = new TextEncoder()
// createFormData 代码见上文
async function createFormData(params = {}, boundary = '') {/* ... */}
// handleUpload 代码见上文
async function handleUpload() {/* ... */}
后续用 5 年陈的安卓机、4 年陈的苹果机均真机测试通过,提交代码,继续摸鱼提升工作技能
代码随便抄,反正也不是什么很重要的东西,但是网上我搜了一圈确实没找到有人写这些的
有帮到的话请点个赞吧,或者关注我的 GitHub