環境
- Node.js 20.11 / TypeScript 5.4
- Playwright 1.44(
@playwright/test) - 実行ブラウザ: Chromium 124(基本 headless、デバッグ時のみ
--headed) - 対象: Vue 3 + Composition API で作られた CtoC サイトの出品フォーム(ドラッグ&ドロップは独自実装)
- 画像: PNG 10枚(各 1〜2MB)
結論から
未公開の下書きであれば、**「部分入替」ではなく「全削除 → 正しい順で再アップロード」**が圧倒的に安定します。
- 下書き状態では「アップロード順 = 表示順」が保証される
- 削除 UI は 1 枚ずつしか触れないが、再アップロードは配列で一括指定できる
- 並び替え UI の仕様に依存しないので、UI 改修に強い
10枚中4枚だけ差し替える処理を部分削除+追加で組んだところ、表示順が壊れて別人の写真が先頭に来る事故が再現性高く出ました。全削除→再アップに切り替えた瞬間、フレークが消えました。
なぜ「部分入替」で並び順が壊れるのか
フォームのサムネイルは v-for で描画されています。よくある実装がこれです。
<li v-for="(img, i) in images" :key="i">
<img :src="img.previewUrl" />
<button @click="remove(i)">削除</button>
</li>
:key="i"(インデックスキー)になっていると、中間の要素を削除した瞬間にインデックスが繰り上がり、Vue のパッチアルゴリズムが DOM ノードを「同じもの」と誤認して再利用します。結果、プレビュー画像と内部の images 配列の対応がずれ、見た目の並びが崩れます。
さらにこのフォームの並び替えは「1つ左へ移動」ボタンのみで、任意位置へのドラッグは無効化されていました。つまり UI 経由で順序を復元する手段が実質存在しない。これが部分入替を諦めた最大の理由です。
手順(ステップ詳細)
- 下書きページを開き、サムネイルが 10 枚描画されるまで待つ
- 削除ボタンを
count()が 0 になるまで先頭から叩く -
page.waitForEvent('filechooser')をPromise.allで仕込みつつ「写真を追加」をクリック -
fileChooser.setFiles(files)に正しい順序の配列を一括で渡す -
input[type=file]にchangeをdispatchEventして Vue 側の state を同期 - サムネイルの
src配列を取得し、期待するファイル名順と突き合わせて assert
実装コード
import { test, expect, Page } from '@playwright/test';
import path from 'node:path';
const FIXTURES = path.resolve(__dirname, 'fixtures');
const ORDERED = ['01.png', '02.png', '03.png', '04.png', '05.png',
'06.png', '07.png', '08.png', '09.png', '10.png'];
/** 1枚以上まとめて追加する。filechooser は必ず横取りする */
async function addImages(page: Page, names: string[]) {
const [chooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.getByRole('button', { name: '写真を追加' }).click(),
]);
await chooser.setFiles(names.map((n) => path.join(FIXTURES, n)));
}
/** 既存サムネイルを全部消す */
async function clearAllImages(page: Page) {
const deletes = page.getByTestId('thumb-delete');
let remaining = await deletes.count();
while (remaining > 0) {
await deletes.first().click();
await expect(deletes).toHaveCount(remaining - 1);
remaining = await deletes.count();
}
}
test('下書きの画像10枚を正順で入れ替える', async ({ page }) => {
await page.goto(process.env.DRAFT_URL!);
await expect(page.getByTestId('thumb')).toHaveCount(10);
await clearAllImages(page);
await addImages(page, ORDERED);
// Vue 側の配列を確実に同期させる
await page.evaluate(() => {
document.querySelectorAll<HTMLInputElement>('input[type=file]').forEach((el) => {
el.dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new Event('input', { bubbles: true }));
});
});
await expect(page.getByTestId('thumb')).toHaveCount(10);
const srcs = await page.getByTestId('thumb').evaluateAll(
(els) => els.map((el) => (el.querySelector('img') as HTMLImageElement).src),
);
ORDERED.forEach((name, i) => expect(srcs[i]).toContain(name.replace('.png', '')));
});
ハマりポイント: setInputFiles() を素朴に呼ぶと OS ダイアログが残る
一番時間を溶かしたのがここです。アップロードボタンを page.click() で押してから setInputFiles() を呼ぶ実装にしていたところ、ボタンクリックで OS のファイル選択ダイアログが開いてしまい、以降のクリックが一切効かなくなる現象が出ました。
Playwright 側は「クリック成功」と判断しているのに、実ブラウザのダイアログがモーダルとして残り続けるためです。回避策はシンプルで、クリックと同時に filechooser イベントを待ち、FileChooser.setFiles() で流し込む。
// NG: ダイアログが残って以降の操作が無反応になる
await page.getByRole('button', { name: '写真を追加' }).click();
await page.locator('input[type=file]').setInputFiles(...);
// OK: ダイアログを横取りしてから流し込む
const [chooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.getByRole('button', { name: '写真を追加' }).click(),
]);
await chooser.setFiles(files);
Promise.all の順序は必ず「待つ → クリック」ではなく 配列に並べるだけで OK です。waitForEvent の Promise を先に生成しておくのがポイントで、クリック後に待つとイベントを取りこぼします。
比較表: 部分更新 vs 全消し更新
| 観点 | 部分入替(差分更新) | 全削除 → 再アップ |
|---|---|---|
| 実装の複雑さ | 高い(差分計算が必要) | 低い |
| 並び順の再現性 | 壊れることがある | 常に安定 |
| 通信量・時間 | 少ない | 多い(10枚で約15MB) |
| 既存画像の再エンコード | なし | あり(画質劣化の懸念) |
| 向いているケース | 公開済み商品、1〜2枚の差し替え | 未公開の下書き、大幅な入れ替え |
| リスク | DOM パッチずれ、UI 依存 | 途中失敗で 0 枚になる |
公開済みの商品では絶対にやらないでください。 全削除→再アップの途中で失敗すると、商品画像が空の状態が一瞬でも公開されます。下書き限定の戦略です。
FAQ
Q. setInputFiles() だけで十分では?
A. input[type=file] が DOM 上に見えていて、v-model が change を購読しているなら十分です。今回は独自ドロップゾーン実装で、change だけでは配列が更新されないケースがあったため dispatchEvent を併用しました。
Q. filechooser がタイムアウトします。
A. ボタンが <label for="..."> で input を間接的に叩いている場合、getByRole('button') で拾えないことがあります。page.pause() でインスペクタを開き、実際にクリックすべきセレクタを確認してください。
Q. ドラッグ&ドロップで並び替えればいいのでは?
A. HTML5 DnD を自前実装している UI は dragTo() が効かないことが多く、mouse.move を細かく刻む必要があります。並び替え UI が限定的なら、再アップロードのほうがコストが低いです。
Q. 10枚制限のバリデーションは?
A. addImages を 1 回の setFiles で済ませ、追加後に toHaveCount(10) で待つと安定します。1枚ずつ追加すると上限エラーのトーストが出て以降が止まります。
まとめ
- 部分入替は「インデックスキー + 限定並び替え UI」の組み合わせで壊れる
- 未公開下書きなら 全削除 → 正順で一括再アップが最も決定的
-
filechooserは必ずwaitForEvent+setFilesで横取りする - Vue 製 UI では
dispatchEventで state を明示的に同期させる
画像差し替えは部分更新派ですか? それとも全消し更新派ですか?
この記事を書いた人
BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。
GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。
👉 業務自動化サービス — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中