HTML や CSV をループで組み立てるとき、「文字列を += でつなぐのは遅いから、配列に push して最後に join したほうがいい」と言われることがあります。
実際に測ってみると、+= と join の差は小さく、どちらがいつも速いという結果にはなりませんでした。そのかわり、+= でつないでいる途中で、毎回その文字列の中身を読む(endsWith や at(-1) で最後の文字を見るなど)と、10万回のループで6秒以上かかりました(Node.js。WebKit では約2秒)。読まないときは2ミリ秒以下です。
この記事では、Node.js v24.14.1 と、Mac の WebKit(Safari と同じ系統のブラウザエンジン)で測った結果を並べます。測ったのは Apple M4 の Mac(macOS 27.0.1)です。
測り方
- 1回分の処理を
performance.now()で囲んで時間を測り、何回か繰り返したうちの真ん中の値(中央値)を載せています - WebKit の
performance.now()は1ミリ秒単位でしか値が返ってこなかったので、WebKit の表で0〜2 msのあたりは「ほぼ差がない」と読んでください - つなぐ部品は
"<li>" + i + "</li>"で、件数(ループの回数)を変えて測りました - 時間はパソコンや、そのときの状態で変わります。ここで見てほしいのは、どのくらい違うか(何倍か、何ミリ秒か)です
+= と push → join は、差が小さかった
function plus(n) {
let s = "";
for (let i = 0; i < n; i++) {
s += "<li>" + i + "</li>";
}
return s;
}
function join(n) {
const a = [];
for (let i = 0; i < n; i++) {
a.push("<li>" + i + "</li>");
}
return a.join("");
}
できた文字列の真ん中の1文字を読むところまでを1回分として、7回の中央値です。
| 件数 | Node.js +=
|
Node.js push→join
|
WebKit +=
|
WebKit push→join
|
|---|---|---|---|---|
| 1万 | 0.41 ms | 0.25 ms | 0 ms | 0 ms |
| 10万 | 3.49 ms | 3.64 ms | 3 ms | 3 ms |
| 100万 | 76.41 ms | 51.17 ms | 31 ms | 40 ms |
10万件までは差が0.2ミリ秒未満で、100万件では Node.js で join が約1.5倍速く、WebKit では += のほうが速くなりました。どちらかがいつも速い、という結果にはなりませんでした。10万件くらいまでなら、読みやすいほうで書いてかまわない差です。
途中で中身を読むと、急に遅くなる
次に、+= で1つ足すたびに、その文字列を何かしら読む形を比べました。「直前の文字を見て、区切りを足すか決める」「最後が閉じタグで終わっているか確かめる」のように、組み立てながら最後のほうを見る書き方です。
const piece = (i) => "<li>" + i + "</li>";
// 各パターンの前に let s = "", c = 0; で初期化しています
// +=のみ
for (let i = 0; i < n; i++) { s += piece(i); }
// +=と毎回length(長さだけ読む)
for (let i = 0; i < n; i++) { s += piece(i); c += s.length; }
// +=と毎回at(-1)(最後の1文字を読む)
for (let i = 0; i < n; i++) { s += piece(i); if (s.at(-1) === ">") c++; }
// +=と毎回endsWith
for (let i = 0; i < n; i++) { s += piece(i); if (s.endsWith("</li>")) c++; }
// +=と毎回slice(-5)
for (let i = 0; i < n; i++) { s += piece(i); if (s.slice(-5) === "</li>") c++; }
3回の中央値です。
Node.js v24.14.1
| 読み方 | 1万 | 3万 | 10万 |
|---|---|---|---|
| +=のみ | 0.19 ms | 0.44 ms | 1.48 ms |
| 毎回 length | 0.55 ms | 0.51 ms | 1.27 ms |
| 毎回 at(-1) | 18.59 ms | 608.89 ms | 6832.45 ms |
| 毎回 endsWith | 17.48 ms | 672.26 ms | 6166.80 ms |
| 毎回 slice(-5) | 18.68 ms | 599.17 ms | 6162.76 ms |
WebKit
| 読み方 | 1万 | 3万 | 10万 |
|---|---|---|---|
| +=のみ | 1 ms | 1 ms | 2 ms |
| 毎回 length | 1 ms | 1 ms | 2 ms |
| 毎回 at(-1) | 47 ms | 209 ms | 2135 ms |
| 毎回 endsWith | 24 ms | 176 ms | 2065 ms |
| 毎回 slice(-5) | 1 ms | 1 ms | 5 ms |
わかったことは3つです。
-
lengthを読むだけなら、どちらの環境でも遅くなりませんでした。文字数の上限を確かめるような使い方は問題ありません -
at(-1)とendsWithは、どちらの環境でも、件数が増えると急に遅くなりました。Node.js では、件数が10倍(1万→10万)になると、時間は約350〜370倍になっています(件数×件数で増えるなら100倍のはずなので、それより大きく増えています。ほかの要因も重なっていそうですが、確かめていません) -
slice(-5)は、Node.js では遅く、WebKit では遅くなりませんでした。どの読み方で遅くなるかは、環境によって違います
最後の数文字を見るだけなので、読む量は毎回わずかです。それなのに件数が増えるほど1回あたりが重くなっていくのは、読む量とは別の何かに時間がかかっているということです。
遅いのは「足した直後に、初めて読むとき」だけ
何に時間がかかっているのかを見るために、+= で組み立て終わった文字列に対して、at(-1) を2回続けて呼び、それぞれの時間を測りました。
let s = "";
for (let i = 0; i < n; i++) { s += "<li>" + i + "</li>"; }
let t = performance.now(); s.at(-1); const first = performance.now() - t;
t = performance.now(); s.at(-1); const second = performance.now() - t;
それぞれ1回ずつ測った値です(Node.js はスクリプトを3回実行して、どれも同じ傾向でした。表は最初に実行したときの値)。
| 件数 | Node.js 最初の at(-1) | Node.js 2回目の at(-1) | WebKit 最初の at(-1) | WebKit 2回目の at(-1) |
|---|---|---|---|---|
| 10万 | 0.544 ms | 0.000 ms | 1 ms | 0 ms |
| 100万 | 9.505 ms | 0.001 ms | 19 ms | 0 ms |
どちらの環境でも、時間がかかるのは最初の1回だけで、2回目はほぼ0でした。最初の1回の時間は、文字列が長いほど増えています。
結果から見ると、+= で足した直後の文字列は、まだ1本にまとまった形になっておらず、初めて中身を読むときに、全体を1本にまとめ直す処理が走っているように見えます(エンジンの内部で実際に何をしているかまでは確かめていません)。
ループの中で「足す → 読む」をくり返すと、このまとめ直しが毎回、その時点の文字列全体に対して起きます。文字列はループのたびに長くなるので、件数が増えるほど1回あたりも重くなり、全体の時間が急に増えていきます。length だけなら、中身を読まずに済むので遅くならない、という結果とも合っています。
ただし、WebKit で slice(-5) が遅くならなかった理由は、この見方だけでは説明できません(確かめていません)。
直し方: 組み立て中の文字列を読まない
確かめたいのが「いま足した部品」のことなら、組み立て中の長い文字列ではなく、その部品のほうを見ます。
// 最後に足した部品を別の変数に持っておく
let s = "", last = "", c = 0;
for (let i = 0; i < n; i++) {
last = piece(i);
s += last;
if (last.endsWith("</li>")) c++;
}
// 配列に貯めて、最後の要素を見る。つなぐのは最後に1回
const a = [];
let c = 0;
for (let i = 0; i < n; i++) {
a.push(piece(i));
if (a[a.length - 1].endsWith("</li>")) c++;
}
const result = a.join("");
| 書き方 | Node.js 10万 | WebKit 10万 |
|---|---|---|
| 毎回 endsWith(組み立て中の文字列) | 6166.80 ms | 2065 ms |
| 最後の部品を別に持つ | 3.97 ms | 6 ms |
| push して最後の要素を見る → join | 5.25 ms | 8 ms |
どちらも、6秒(Node.js)や2秒(WebKit)から数ミリ秒に戻りました。何も読まない +=(Node.js で1.48ミリ秒)と同じ桁です。
join が速い、というよりも、配列に貯めている間は組み立て中の長い文字列が存在しないので、うっかり読むことがない、という効き方です。
1万件なら気にしなくていい
Node.js では、毎回 endsWith を呼んでも、1万件なら約17ミリ秒でした(WebKit では24ミリ秒)。画面に1回出すだけの一覧なら、気づかない程度です。
困るのは、件数が増えたときです。手元の少ない件数で試しているときは速いのに、Node.js では本番のデータで3万件になると約0.7秒、10万件で約6秒、と急に重くなります。件数が決まっていない処理(ログ、CSV の書き出し、検索結果など)でこの形を見つけたら、直しておくと安心です。
AI が書いた文字列の組み立てを見るときの確認ポイント
- ループの中で
+=している文字列に対して、同じループの中でendsWith/at(-1)/sliceなどで中身を読んでいないか - 呼んでいるなら、それは「いま足した部品」を見れば済む処理ではないか
-
lengthだけなら、毎回読んでも遅くならなかった - 「
+=は遅いのでjoinに書き換えました」という提案は、件数が10万程度までなら、速さの面ではほとんど差がない可能性がある(読みやすさで選んでよい) - 速さを理由に書き換えるなら、変更の前と後で、実際のデータの件数で測ってから決める
未経験者向けの講座を運営しています
未経験から Next.js + Supabase + Claude Code で Webアプリを公開するまで を、全24セッションで体系化した教材です。Claude Code を学習パートナーにする CLAUDE.md と学習モード(learner / developer)の設計までセットで含みます。
- ▶ ライブデモ(インストール不要・無料で今すぐ見られます)→ https://trial-web-ayies128s-projects.vercel.app/?ref=qiita
- 教材完全版+月5,500円のメンタリング(全24セッション+チャット質問し放題)→ https://menta.work/plan/20251?ref=qiita
- 無料体験版(git clone して自分の手元で動かす・最初の数セッション分・⭐ Star もよろしくお願いします)→ https://github.com/ayies128/next-ai-camp-trial
- YouTube『AIエンジニア情報局』(AI×開発ニュースを1本5分でキャッチアップできる別運営チャンネル・無料)→ https://www.youtube.com/channel/UC1rXVD9WYsQPQEWZyd-A1KA/?ref=qiita
※ 中上級者には易しすぎる内容なので、初心者の知り合いへの紹介や社内研修の参考としてどうぞ。