2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

文字列の += は join より遅い? 差は小さく、ループ中の endsWith で Node.js は10万回6秒

2
Posted at

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)の設計までセットで含みます。

※ 中上級者には易しすぎる内容なので、初心者の知り合いへの紹介や社内研修の参考としてどうぞ。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?