微信小程序におけるデータ送信の技術的制限
ユーザープロフィールの更新やダイナミック投稿など、メディアファイルを扱う機能を実装する際、ローカル選択されたリソースをサーバーへ転送する手法が必要です。Web ブラウザ環境では FormData API を利用することで、テキストデータとバイナリデータを単一の HTTP リクエストに統合することが容易ですが、微信小程序の実行環境(JavaScriptCore)にはこの標準 Web API が含まれていません。
そのため、new FormData() と記述しても「FormData is not defined」のエラーが発生します。一般的な回避策として、まず wx.uploadFile を介して画像を個別にアップロードし、取得した URL と他のフォームデータを wx.request で送信する方法が取られます。しかし、このアプローチは通信ラウンドトリップが増加し、コードの結合度が高まり、ネットワーク不安定時の状態管理が困難になるという欠点があります。
より効率的なソリューションとして、ブラウザ側の挙動をシミュレートし、単一の POST リクエストで複数のフィールドとファイルを送信するための専用クラスの構築が求められます。
multipart/form-data プロトコルの構造的理解
独自のフォームデータ形式を構築するためには、ターゲットとする HTTP ヘッダー規格の理解が不可欠です。multipart/form-data は、複数の異なるデータタイプを一つのメッセージボディに収容するために設計されています。
このフォーマットでは、境界線文字列(boundary)によってコンテンツが区切られています。各パートは以下の要素で構成されます。
- 境界開始:
--に続けて boundary 文字列が続く。 - ヘッダー情報:
Content-Dispositionによりフィールド名やファイル名を指定。ファイルの場合はContent-Typeで MIME タイプも併記。 - データ本体: ヘッダー後には改行が挟まれ、その後に対応するテキストまたはバイト列が続く。
- 終了: すべてのパート完了後、境界線文字列の両側に
--を付与して全体を終了。
具体的には、境界線を ----WebKitFormBoundaryExample とした場合、リクエストボディは以下のようなバイナリ構造となります。
--WebKitFormBoundaryExample
Content-Disposition: form-data; name="description"
テスト投稿です
--WebKitFormBoundaryExample
Content-Disposition: form-data; name="image"; filename="photo.jpg"
Content-Type: image/jpeg
[バイナリデータ]
--WebKitFormBoundaryExample--
カスタム FormData クラスの構造化設計
上記の仕様に基づき、ミニプログラム内で動作する互換ライブラリを実装します。ここでは ES6 のクラス構文を使用し、可読性と保守性を高めています。
// utils/multipart-form.js
class MiniFormData {
constructor() {
this.boundary = 'mini-program-boundary-' + Date.now();
this.parts = [];
this.fs = wx.getFileSystemManager();
}
/**
* テキストデータを追加
* @param {string} key フィールド名
* @param {string|number} value データ値
*/
addText(key, value) {
const header = [
`--${this.boundary}`,
`Content-Disposition: form-data; name="${key}"`,
'',
`${value}`
].join('\r\n');
this.parts.push(this.toBuffer(header));
}
/**
* ファイルデータを追加
* @param {string} key フィールド名
* @param {string} path ローカルファイルパス
*/
async addFile(key, path) {
try {
const info = this.fs.getFileInfo({
filePath: path,
success: (res) => res
});
// 拡張子から MIME タイプを判定(簡易マップ参照)
const ext = path.split('.').pop().toLowerCase();
const mimeType = mimeMap[ext] || 'application/octet-stream';
const headerPart = [
`--${this.boundary}`,
`Content-Disposition: form-data; name="${key}"; filename="${path.split('/').pop()}"`,
`Content-Type: ${mimeType}`,
''
].join('\r\n');
// バイナリファイルを読み出し
const buffer = this.fs.readFileSync(path);
this.parts.push(this.toBuffer(headerPart));
this.parts.push(buffer);
} catch (err) {
console.error('ファイル読み込み失敗:', err);
}
}
/**
* システム全体の最終バッファを生成
* @returns {{body: ArrayBuffer, contentType: string}}
*/
finalize() {
const ending = `\r\n--${this.boundary}--\r\n`;
this.parts.push(this.toBuffer(ending));
// パーツを結合
let totalLength = 0;
this.parts.forEach(p => totalLength += p.byteLength);
const resultBuffer = new Uint8Array(totalLength);
let offset = 0;
this.parts.forEach(part => {
if (part instanceof Uint8Array) {
resultBuffer.set(part, offset);
} else {
resultBuffer.set(new Uint8Array(part.buffer), offset);
}
offset += part.byteLength;
});
return {
body: resultBuffer.buffer,
contentType: `multipart/form-data; boundary=${this.boundary}`
};
}
// Helper: String to ArrayBuffer
toBuffer(str) {
const encoder = new TextEncoder();
return encoder.encode(str);
}
}
この実装では、非同期処理を用いてファイルシステムへのアクセスを行い、最終的な ArrayBuffer を生成しています。生成されたバディを wx.request の data プロパティに設定し、同時に header['Content-Type'] を適切な形式に変更することで、サーバー側は通常のフォーム送信と同様にデータを解釈できます。
MIME タイプマッピングの補足
ファイルアップロードの際、サーバーは正しくファイル形式を認識できるよう、Content-Type の正確な指定が必要となります。一般的に使用される拡張子と MIME タイプの対応表を用意しておきます。
const mimeMap = {
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'pdf': 'application/pdf'
};