Google Apps Script (GAS) でWebアプリを開発していた際、「サーバー側の処理は終わっているのに、完了メッセージが表示されずに画面が固まる(ように見える)」 という現象に遭遇しました。
原因はJavaScriptの特性による「描画ブロック」でした。 今回は、その原因と setTimeout を使った解決策をメモとして残します。
🛑 発生した問題
GASの google.script.run を使い、以下のようなフローで処理を実装していました。
-
「処理中...」 というモーダル(プログレスバー)を表示。
-
サーバー側で重たい計算処理を実行(数分かかる)。
-
処理完了後、サーバーから大量のデータを受け取る。
-
モーダルの表示を 「完了!」 に切り替える。
-
受け取ったデータを元に、裏側の画面(巨大なテーブル)を 再描画 する。
しかし、実際に動かしてみると…
-
期待: モーダルが「完了!」に変わる ➔ その後、裏の画面が更新される。
-
現実: モーダルは「処理中...」のまま固まる ➔ 数秒後、いきなり画面が更新される(「完了!」を見る暇がない、あるいは表示されない)。
ユーザーからすると、「処理が終わったのか、フリーズしているのか分からない」 という不安な状態になっていました。
💻 修正前のコード(NG例)
google.script.run
.withSuccessHandler(data => {
// 1. モーダルを「完了」状態にする(軽い処理)
updateModalToComplete("処理が完了しました");
// 2. 画面全体を再描画する(重い処理)
// ※ここで大量のDOM操作が発生
renderHeavyTable(data);
// 3. 最後にローディングを解除
setLoading(false);
})
.serverSideFunction();
🔍 原因:JavaScriptはシングルスレッド
JavaScriptは基本的に 「シングルスレッド(一人の作業員)」 で動いています。また、ブラウザの 「画面の描画(レンダリング)」 も、このスクリプト実行の合間に行われます。
上記のNGコードでは、以下のことが起きていました。
-
updateModalToCompleteで「完了」の文字に変更する命令を出す。
👉 しかし、ブラウザはまだ描画しない(スクリプトの実行が忙しいため)。 -
休む間もなく
renderHeavyTable(重い処理)が始まる。
👉 ブラウザは全力で計算とDOM生成を行うため、画面更新がロックされる。 -
スクリプトが全部終わって初めて、ブラウザは画面を描画する。
👉 ユーザーには「処理中」から一気に「更新後の画面」に飛んだように見える。
つまり、「完了!」と表示する命令は出していたけれど、重い処理に邪魔されて、ユーザーの目に届く前に上書きされてしまったようです。
✅ 解決策
パターンA:setTimeout を使う方法(基本)
重たい描画処理を setTimeout で少しだけ遅らせることで、解決しました。
google.script.run
.withSuccessHandler(data => {
// 1. まずモーダルを「完了」にする(最優先)
updateModalToComplete("処理が完了しました");
// 2. 重たい処理を「非同期(後回し)」にする
setTimeout(() => {
try {
// ここで重い描画処理を実行
renderHeavyTable(data);
} catch(e) {
console.error(e);
} finally {
setLoading(false);
}
}, 100); // 0.1秒だけ待つ
})
.serverSideFunction();
[追記] コメントで頂いた「よりスマートな解決策」
記事公開後、コメントにて async/await を使った、より可読性の高い書き方 を教えていただきました。 GASの google.script.run.withSuccessHandler の仕様を活かした、ネスト(入れ子)が深くならない素晴らしい方法ですので紹介します。
パターンB:async/await を使う方法(発展)
withSuccessHandler に渡す関数は、GAS側ではその「返り値」を利用しません(何を返しても無視されます)。 この仕様を利用し、ハンドラ関数を async function(非同期関数)にしてしまうことで、setTimeout の待ち時間を 1行で書くことができます。
google.script.run
.withSuccessHandler(async (data) => { // ★ ここに async をつける
// 1. まずモーダルを「完了」にする
updateModalToComplete("処理が完了しました");
// 2. ここで0.1秒待つ (この間にブラウザが画面を描画する!)
await new Promise(resolve => setTimeout(resolve, 100));
// 3. 続きの処理 (入れ子にならず、そのまま下に書ける)
try {
renderHeavyTable(data);
} catch(e) {
console.error(e);
} finally {
setLoading(false);
}
})
.serverSideFunction();
この書き方のメリット:
-
読みやすい: 「更新する」→「待つ」→「重い処理」という流れが、上から下へ一直線になるため直感的です。
-
ネストが浅い: setTimeout(() => { ... }) のような入れ子がなくなるため、コードが右に寄っていかずスッキリします。
※ 注意点
async をつけた関数は自動的に Promise オブジェクトを返します。ライブラリや仕組みによってはコールバックの返り値で挙動が変わるもの(例:falseを返すと中断するなど)があります。返り値を使って挙動を変える仕組みの場合は、誤作動の原因になります。
📝 まとめ
- JavaScriptで DOM操作(画面変更)をしても、即座に反映されるわけではない。
- 直後に重い処理が続くと、画面の更新(再描画)がブロックされてしまう。
- 「まずはユーザーに完了を伝える」 ことが重要な場合、その後の重い処理を
setTimeoutで逃がしてあげると、スムーズなUIになる。 - 返り値が重要でない場合、
async/await(パターンB)で記述すると、コードの可読性が上がる。
GASのWebアプリは一度に大量のデータを扱うことが多いため、このテクニックは必須級だと感じました。