问题记录:Taro 在微信小程序里使用 FormData

0 阅读6分钟

我是 Moushu,分享一个近期在工作中遇到的问题,我在互联网上搜了一圈没有看到相关解决方案,所以写了一篇掘金,分享给大家

有帮到的话请点个赞吧,或者关注我的 GitHub

背景

由于一些后端的问题,我拿到了一个需要传递 FormData 的上传文件接口,里面有多个字段,其中一个是 Blob 里面塞文件流

尝试

对于一名高强度摸鱼工作数年的资深前端来说,这种基本功一样的东西应该是手到擒来

7949556b-258a-45ac-9476-a2c47b37f9d4(1).gif

我迅速命令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

5ad65191-9426-4644-957b-c1f0ec4e936c(1).gif

女娲补天计划之放弃补天

经过简单查看 FormDataBlob 的标准文档后,我放弃了女娲补天的计划——实现一个标准的 FormDataBlob 太麻烦了

更何况公司给的这点钱,我 8 小时的上班时间没有摸掉 7 小时已经很给面子了

d1b31333-d687-4583-82b4-c33f9420fb3d(1).gif

所以我决定投机取巧:

  1. 微信小程序环境支持 ArrayBuffer,刚好这个类型的数据可以塞进 body 里面提交
  2. 虽然不能直接实现一个 FormDataBlob 但是构造一个符合 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 里用到之外,请求头也要用

那么答案就很简单了:

  1. 生成一个边界字符串 boundary
  2. 根据标准文档,构建字符串,然后用 TextEncoder 处理为 Uint8Array 然后获取 ArrayBuffer
  3. 把这个 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
}
9cd26134-eb75-406b-b6aa-e5ad2a8b8478.png

考虑到公司已经几年不涨工资了,我懒得优化代码,只是将最开始的代码修改成这样:

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 然后真机上没有

7faa7264-2178-444c-9934-b5c2c816651a.gif

我紧急看了一圈问题,发现要解决别无他法只能自行实现一个 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()

最终成品

这么一个小小的功能,在微信小程序的限制下,愣是花了我好几个小时的时间,中间还出了页面崩溃的问题,不过最终结果是好的

998057b6-5b88-472f-92e3-44265c01d2ec.gif

最终代码的结构大概是:

// getTextEncoderPolyfill 代码见上文
const { TextEncoder } = getTextEncoderPolyfill(window)
const encoder = new TextEncoder()

// createFormData 代码见上文
async function createFormData(params = {}, boundary = '') {/* ... */}

// handleUpload 代码见上文
async function handleUpload() {/* ... */}

后续用 5 年陈的安卓机、4 年陈的苹果机均真机测试通过,提交代码,继续摸鱼提升工作技能

代码随便抄,反正也不是什么很重要的东西,但是网上我搜了一圈确实没找到有人写这些的

有帮到的话请点个赞吧,或者关注我的 GitHub