Vueで、ファイルを複数ダウンロードしてv-for
リストレンダリングさせる機能を実装したかったのですが、少し工夫が必要だったのでメモ。
ズバリ
<ul>
<li v-for="attachment in attachments()">
<div>{{attachment.name}}</div>
<div>{{attachment.size / 1024}} KB</div>
</li>
</ul>
module.exports = {
// 描画トリガーは親コンポーネントからの受け渡しと仮定
// result: ["aaa.bin", "bbb.bin", "ccc.bin"]
props: {
result: Array
},
data(){
return {
files: [],
reset: false
};
},
method: {
attachments(){
if(!this.result){
this.files = [];
return this.files;
}
if(this.reset){
this.files = [];
}
const filenames = this.result;
this.reset = (this.files.length === filenames.length) ? true : false;
if(this.files.length === 0){
filenames.map((filename)=>{
fetch(`https://download.link/files/${filename}`, {method: "GET"})
.then((res)=>{
if(res.ok){
this.files.push(new File([res.blob()], filename));
}
})
.catch(error => console.log(error));
});
}
return this.files;
}
}
};
解説
Vueおさらい
-
Vueは、
data()
内プロパティに何らかの状態変化を検知するとDOMを再描画する仕組みです。- (全ての状態変化を検知可能な訳ではありません)
バインディングにメソッドが含まれる場合は、再描画の度にメソッドが再実行されます。
<div v-for="method()"></div>
<span>{{method()}}</span>
Bad: fetch()
を戻した場合
attachments(){
const filenames = this.result;
this.files = filenames.map((filename)=>{
return fetch(`https://download.link/files/${filename}`, {method: "GET"})
.then(res => new File([res.blob()], filename));
});
return this.files;
}
attachments()
が終了した直後のfiles[]
内は、ファイルの実体にはなっておらず、未解決状態のPromise
になっています。
// [aaa.bin, bbb.bin, ccc.bin]
[Promise<pending>, Promise<pending>, Promise<pending>]
そして時間経過と共に、ダウンロードが完了した順から
// [aaa.bin, bbb.bin, ccc.bin]
[Promise<pending>, Promise<resolved>(File), Promise<pending>]
と言うようにPromise
は解決し、ファイルの実体へと変化します。
つまりv-for
がループ処理を開始する瞬間のfiles[]
内はまだ未解決Promise
なので、目的の情報は何も取り出せません。
<li v-for="attachment<Promise> in attachments()<Promise[]>">
<div>{{attachment<Promise>}}</div>
</li>
そしてVueは、"配列内の値"の変化は検出不可能なので、時間経過でPromise
からFile
に変化しても、再描画はされません。
Good: 配列を戻した場合 (最初のコード)
上から順を追って見て行きます。
if(!this.result){
this.files = [];
return this.files;
}
親コンポーネントから受け取ったresult
が空だった時のエスケープです。
v-for
には[]
が渡されるので、何も描画されません。
if(this.reset){
this.files = [];
}
const filenames = this.result;
this.reset = (this.files.length === filenames.length) ? true : false;
重要な部分その1。
次の再描画でfiles[]
をリセットするかどうかの判定です。
詳しく言うと、次の再描画(再実行)が親コンポーネントからのresult
受け渡しによるものか、files[]
の変化によるものかを識別する為に存在します。
親コンポーネントからresult
を受け渡された時、もしくはファイル追加による再描画であれば、files[]
(ダウンロード済ファイル)とfilenames[]
(全ファイル)の配列長は一致しないので、フラグは立たず次の再描画時もリセットはされません。
そして全てのダウンロードが完了した時の再描画で、2つの配列長が一致し、フラグが立ちます。
ファイル追加による再描画はそれ以上発生しないので、もし次に再描画が発生するとしたら、親コンポーネントからresult
を受け取った時だけとなる為、識別が可能となります。
if(this.files.length === 0){
filenames.map((filename)=>{
fetch(`https://download.link/files/${filename}`, {method: "GET"})
.then((res)=>{
if(res.ok){
this.files.push(new File([res.blob()], filename));
}
})
.catch(error => console.log(error));
});
}
return this.files;
重要な部分その2。
まず、files[]
(ダウンロード済ファイル)が0
個の時のみfetch()
が実行されます。
(無限ループ防止用)
そしてfetch()
は、files.push(new File)
する.then()
ハンドラの登録だけ行い、そのまま手付かずのfiles[]
を戻して終了します。
この直後のv-for
には[]
が渡るので、何も描画はされません。
しかしVueは、"配列内の値"の変化は検知不可能ですが、.push()/.pop()
など、"配列自体"の変化は検知可能です。
なので、ダウンロードが完了しファイル実体が.push()
される度にattachments()
が再実行され、中身が追加されたfiles[]
がv-for
に渡り、リストレンダリングが可能となる仕組みです。
まとめ
これに辿り着くまで、無限ループや無検知など、割と試行錯誤がありましたが結構勉強になりました。
途中「もうFetchは諦めて同期XHRに逃げても良いんじゃない」っていう悪魔の囁きが聞こえた気がしましたが、何とか打ち勝てました。