Old-School Front-End · Frameworks / 2026 ・ No.03

いにしえのフロントエンド入門 フレームワーク編

第3回 歴史②:React 以後 ── 作りなおして比べる流派と、シグナルの流派

公開

4つのブラウザの画面を 2×2 に並べたもの。どれも「経費の明細」の表で、第1回と同じ8つの操作をしたあと。左上は書き方 G(Vue 3.5)、右上は H(Svelte 5)、左下は I(Solid 1.9)、右下は J(Angular 22)。4つとも明細 3 件、金額 14720 と 100(ドル)、空の行が1つ、合計 29,843 円
React 以後の4つで同じ明細の表を書き、厳しい CSP のまま同じ操作をしました。4つとも件数も合計も正しい(筆者がクラウド上の Linux で、2026年11月1日に撮影。ウインドウの上の帯は説明のために描き足したもの)

第2回では、2010年の3つの流派が、件数をずらさずに、しかも React より速く動くことを測りました。それでも 2013年に出た React は広まり、そのあと Vue、Angular、Svelte、Solid と、新しい答えが続きます。
第3回は、同じ明細の表をこの4つの最新版で書き、第1回からの React と並べて測ります。最後に、画面をサーバーで作る流れにもふれます。

一

React が持ちこんだもの

新井前回、React がいちばん遅かったじゃないですか。なんで勝ったんですか?

古井React が売ったのは速さではなく、わかりやすさだった。「状態が決まれば、画面は1つに決まる」。

全15回の路線図(いまここ:第3回)

1 なぜ FW 2 jQuery→MVC 3 React 以後 4 React の一歩 5 状態と再描画 6 フォーム 7 データ取得 8 再描画のコスト 9 状態の置き場所 10 画面遷移 11 楽観的更新 12 Server Comp. 13 キャッシュ 14 テスト 15 公開と選び方 いまここ なぜと歴史 React の基本 速さと置き場所 画面とサーバー Next.js 仕上げ

第2回の Knockout や AngularJS は、値と画面を両方向につなぎました。画面が大きくなると、ある値を変えたのが入力欄なのか、ほかの計算なのか、別の見張りなのかを追うのがむずかしくなります。React は向きを片方向に決めました。状態を変えるのは setRows のような決まった入口だけで、画面は状態から毎回作りなおし、前回と比べてちがう所だけを書く(第1回の書き方 C)。比べるための画面の形の写しを、仮想 DOM と呼びます。

React 以後(年表)

React 以後(年表) 2013 2015 2017 2019 2021 2023 2025 React 2013.5 フック 19・RSC Vue 1.0 3.0 Angular 2.0 シグナル zoneless Svelte 3 5 runes Solid 1.0 Next.js 1.0 App Router 2016年ごろ「作りなおして比べる」が主流に。2020年代はシグナルとサーバーへ

この考え方は広く受け入れられ、そのあとの流派も「状態 → 画面」の片方向を土台にしています。ちがうのは、どこを書きかえるかをどう決めるかです。4つを、第1回からの明細の表で書きました。どれもビルドして JavaScript にしてから配り、サーバーはフロントエンド編 第15回の厳しい CSP のままです。

二

Vue:テンプレートと、見張る値

Vue(書き方 G)は 2014年に、AngularJS を使っていた開発者が、もっと軽いものとして作り始めました。HTML に近いテンプレートと v-model は AngularJS に似ていますが、値は ref に入れ、どの値がどこで読まれたかを Vue が覚えています(第2回の Knockout に近い)。

<!-- 書き方 G:Vue 3.5。値は ref に入れる。テンプレートの v-model と {{ }} が値と画面をつなぎ、ビルドのときに JavaScript になる -->
<script setup>
import { ref, computed, onMounted } from 'vue';

let nextId = 1;
const newRow = () => ({ id: nextId++, item: '', amount: '', currency: 'JPY' });
const T = ROWS_TEXT;
const { formatYen, needsReceipt } = window; // app.js の関数(テンプレートからは、ここに書いたものしか見えない)

const rows = ref([newRow()]);
const rate = ref(null);

function yenOf(row) { … } // 第1回と同じ(円に直す)
// 合計:中で読んだ値(行・金額・通貨・レート)が変わったときだけ、計算しなおす
const total = computed(() => calcTotal(rows.value.map(yenOf).filter((y) => y !== null && !Number.isNaN(y)).map((amount) => ({ amount }))));

const add = () => rows.value.push(newRow());
const remove = (row) => {
  rows.value = rows.value.filter((r) => r !== row);
  if (rows.value.length === 0) rows.value.push(newRow());
};
onMounted(() => { fetch('/api/rates').then((res) => res.json()).then((data) => { rate.value = data; }).catch(() => {}); });
</script>

<template>
  <h1>{{ T.heading }} <span id="count">{{ T.count(rows.length) }}</span></h1>
  <table>
    <tbody id="items">
      <tr v-for="(r, i) in rows" :key="r.id">
        <td><input name="item" v-model="r.item"></td>
        <td><input name="amount" v-model="r.amount"></td>
        <td><select name="currency" v-model="r.currency">…</select></td>
        <td><button @click="remove(r)">{{ T.remove }}</button></td>
      </tr>
    </tbody>
  </table>
  …
  <output id="total">{{ formatYen(total) }}</output>
</template>

件数(★)はテンプレートに {{ T.count(rows.length) }} と書くだけです。ただし画面を直すときは、React と同じく、コンポーネントのテンプレートを作りなおして前回と比べます。値の見張りは細かく、画面の書きかえはコンポーネント単位。この組み合わせが、七の数字に出ます。

三

Angular:作りなおしと、シグナル

Angular(書き方 J)は、AngularJS を TypeScript で一から作りなおしたもので、2016年に 2.0 が出ました。名前は似ていますが、別物です。長いあいだ、zone.js という仕組みで「何か起きたかもしれない」ときに画面全体を確かめていましたが、2023年の 16 からシグナルが入り、2025年の 21 からは zone.js なしが新しいアプリの既定になりました。

// 書き方 J:Angular 22。値はシグナル(signal)に入れ、computed で合計を出す。テンプレートはビルドのときに JavaScript になる。
// 21 からは zone.js なし(zoneless)が既定。シグナルが変わったときだけ、画面を確かめなおす
import { Component, ChangeDetectionStrategy, signal, computed } from '@angular/core';

type Row = { id: number; item: string; amount: string; currency: string };
type Rate = { rate: number; date: string };

@Component({
  selector: 'rows-app',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <h1>{{ T.heading }} <span id="count">{{ T.count(rows().length) }}</span></h1>
    <table>
      <tbody id="items">
        @for (r of rows(); track r.id; let i = $index) {
          <tr>
            <td><input name="item" [value]="r.item" (input)="change(r.id, 'item', $event)"></td>
            <td><input name="amount" [value]="r.amount" (input)="change(r.id, 'amount', $event)"></td>
            <td><select name="currency" [value]="r.currency" (change)="change(r.id, 'currency', $event)">…</select></td>
            <td><button (click)="remove(r.id)">{{ T.remove }}</button></td>
          </tr>
        }
      </tbody>
    </table>
    …
    <output id="total">{{ totalText() }}</output>
  `,
})
class RowsComponent {
  T = ROWS_TEXT;
  max = MAX_ROWS;
  rows = signal<Row[]>([newRow()]);
  rate = signal<Rate | null>(null);

  private yenOf(row: Row, rate: Rate | null) { … } // 第1回と同じ(円に直す)
  // 合計:中で読んだシグナル(行・レート)が変わったときだけ、計算しなおす
  total = computed(() => calcTotal(this.rows().map((r) => this.yenOf(r, this.rate())).filter((y): y is number => y !== null && !Number.isNaN(y)).map((amount) => ({ amount }))));
  totalText = computed(() => formatYen(this.total()));
  receipt = computed(() => needsReceipt(this.total()));

  change(id: number, name: 'item' | 'amount' | 'currency', event: Event) {
    const value = (event.target as HTMLInputElement).value;
    this.rows.update((rows) => rows.map((r) => (r.id === id ? { ...r, [name]: value } : r)));
  }
  add() { … }
  remove(id: number) { … }
}

signal は値の入れ物、computed はそこから計算する値です。テンプレートの @for は track r.id で行を見分けます。型の宣言がある分、4つの中でいちばん行が多くなりました。

四

Svelte:コンパイラが書く

Svelte(書き方 H)は 2016年に出て、コンパイラという立場をとりました。ブラウザで比べる仕事をせず、ビルドのときに「この値が変わったら、この文字を書きかえる」という JavaScript をはき出します。2024年の Svelte 5 からは、値を $state、計算する値を $derived と書きます(runes)。

<!-- 書き方 H:Svelte 5。$state に入れた値は、ふつうの変数のように読み書きできる。コンパイラが、どこを書きかえるかをビルドのときに決める -->
<script>
  let nextId = 1;
  const newRow = () => ({ id: nextId++, item: '', amount: '', currency: 'JPY' });
  const T = ROWS_TEXT;

  let rows = $state([newRow()]);
  let rate = $state(null);

  function yenOf(row) { … } // 第1回と同じ(円に直す)
  // 合計:中で読んだ値が変わったときだけ、計算しなおす
  const total = $derived(calcTotal(rows.map(yenOf).filter((y) => y !== null && !Number.isNaN(y)).map((amount) => ({ amount }))));

  function remove(row) {
    rows = rows.filter((r) => r !== row);
    if (rows.length === 0) rows.push(newRow());
  }
  fetch('/api/rates').then((res) => res.json()).then((data) => { rate = data; }).catch(() => {});
</script>

<h1>{T.heading} <span id="count">{T.count(rows.length)}</span></h1>
<table>
  <tbody id="items">
    {#each rows as r, i (r.id)}
      <tr>
        <td><input name="item" bind:value={r.item}></td>
        <td><input name="amount" bind:value={r.amount}></td>
        <td><select name="currency" bind:value={r.currency}>…</select></td>
        <td><button onclick={() => remove(r)}>{T.remove}</button></td>
      </tr>
    {/each}
  </tbody>
</table>
…
<output id="total">{formatYen(total)}</output>

rows.push(newRow()) のように、ふつうの配列のように書いても画面がついてきます。4つの中でいちばん短い 44 行でした。

五

Solid:1回だけ動くコンポーネント

Solid(書き方 I)は、React と同じ JSX で書きますが、中身は正反対です。コンポーネントの関数は最初の1回しか動きません。JSX の {…} の中で読んだシグナルが変わると、その場所だけが書きかわります。

// 書き方 I:Solid 1.9。値はシグナル(ここでは行の集まりを store)に入れる。コンポーネントの関数は最初の1回しか動かず、
// JSX の {…} の中で読んだシグナルが変わると、その場所だけが書きかわる
import { createSignal, createMemo, For } from 'solid-js';
import { createStore } from 'solid-js/store';
import { render } from 'solid-js/web';

let nextId = 1;
const newRow = () => ({ id: nextId++, item: '', amount: '', currency: 'JPY' });
const T = ROWS_TEXT;

function Rows() {
  const [rows, setRows] = createStore([newRow()]);
  const [rate, setRate] = createSignal(null);

  const yenOf = (row) => { … }; // 第1回と同じ(円に直す)
  // 合計:中で読んだ値が変わったときだけ、計算しなおす
  const total = createMemo(() => calcTotal(rows.map(yenOf).filter((y) => y !== null && !Number.isNaN(y)).map((amount) => ({ amount }))));
  const change = (i, name) => (e) => setRows(i(), name, e.target.value);
  const remove = (row) => {
    const rest = rows.filter((r) => r !== row);
    setRows(rest.length > 0 ? rest : [newRow()]);
  };

  return (
    <>
      <h1>{T.heading} <span id="count">{T.count(rows.length)}</span></h1>
      <table>
        <tbody id="items">
          <For each={rows}>{(r, i) => (
            <tr>
              <td><input name="item" value={r.item} onInput={change(i, 'item')} /></td>
              <td><input name="amount" value={r.amount} onInput={change(i, 'amount')} /></td>
              <td><select name="currency" value={r.currency} onChange={change(i, 'currency')}>…</select></td>
              <td><button onClick={() => remove(r)}>{T.remove}</button></td>
            </tr>
          )}</For>
        </tbody>
      </table>
      …
      <output id="total">{formatYen(total())}</output>
    </>
  );
}

見た目は React、動きは Knockout の流れ。Svelte・Angular(シグナル)とあわせて、この3つをシグナルの家族と呼ぶことにします。

六

同じ8つの操作

# 第1回と同じ8つの操作。フロントエンド編 第15回の厳しい CSP のまま
  書き方件数のずれ合計のずれCSP のエラー追加・削除のあとのカーソル
  G Vue 3.5       0 / 8         0 / 8         0             「行を追加」 / BODY
  H Svelte 5      0 / 8         0 / 8         0             「行を追加」 / BODY
  I Solid 1.9     0 / 8         0 / 8         0             「行を追加」 / BODY
  J Angular 22    0 / 8         0 / 8         0             「行を追加」 / BODY
  C React 19      0 / 8         0 / 8         0             「行を追加」 / BODY
# 3 言語とも同じ。どれもビルドしてから配るので、文字から関数を作らない

4つとも、件数も合計も一度もずれず、厳しい CSP でもエラーは0でした(中国語・英語のページでも同じ)。第2回の Backbone のひな形や Knockout とちがい、テンプレートはビルドのときに JavaScript になっているので、ブラウザで文字から関数を作りません。カーソルは、どれも何も書かなければ何もしません。

# 3行の表で、1行目の金額に 1・4・7・2・0 と打ったとき、1文字ごとの画面の変化(MutationObserver)
  書き方変化の記録中身
  G Vue 3.5       1           合計の文字を入れかえ
  H Svelte 5      1           合計の文字だけ
  I Solid 1.9     2           合計の文字と、注意書きの hidden
  J Angular 22    1           合計の文字だけ
  C React 19      23          21 は React が name・type を同じ値で付けなおす内部の手順

1文字で書きかわったのは、どれも合計の文字だけ(Solid は注意書きの hidden も付けなおしました)。React だけが、入力欄の属性を同じ値で付けなおす手順を 21 回ふくんでいます。画面に届く変化は、どの書き方もほとんど同じです。ちがいは、そこへ行くまでのJavaScript の仕事の量に出ます。

七

5000 行で、2つの家族

行を 10・1000・5000 にして、1文字の入力で JavaScript が働いた時間と、ブラウザが描いた時間を測りました。Vue・Svelte・Angular は画面の書きかえを少しあとに回すので、第2回の測り方(input を送って戻るまで)では足りません。今回は Chromium の記録(tracing)から、20 文字ぶんの時間を足しました。第2回の Knockout と AngularJS も、同じ測り方で測りなおしています。

# 行を 10・1000・5000 にして、1文字の入力にかかった時間と、開くまでの時間
  書き方1文字の JS(ms)描く(ms)開く(ms)
                  10     1000   5000      5000          1000   5000
  C React 19      2.0    20.4   99.6      124.3         603    2,280
  G Vue 3.5       1.7    15.1   91.5      105.5         652    2,208
  H Svelte 5      0.8    3.0    9.6       106.0         574    2,540
  I Solid 1.9     1.3    2.1    11.4      117.5         473    1,936
  J Angular 22    1.2    2.4    6.7       127.7         575    2,079
  E Knockout      0.8    1.6    5.2       107.2         759    2,793
  F AngularJS     1.7    3.1    24.4      125.1         814    3,432
# JS と描く時間は、Chromium の記録(tracing)から 20 文字ぶんを足して割った。3回の中央値。第2回(input を送って戻るまで)とは測り方がちがう
端末の画面。node ../keys3.js 5000 の結果。rows-react.html は JS 107.2 ms・描く 130.2 ms、rows-vue.html は 100.0・117.6、rows-svelte.html は 10.9・128.3、rows-solid.html は 11.9・134.3、rows-angular.html は 6.6・147.6
5000 行の表で1文字打つたびの時間を、1回だけ測った画面です(本文の数字は3回の中央値)(筆者がクラウド上の Linux で、2026年11月1日に撮影。ウインドウの上の帯は説明のために描き足したもの)

5000 行で1文字打つと、JavaScript は何ミリ秒働いたか

5000 行で1文字打つと、JavaScript は何ミリ秒働いたか(実測) C React 19 99.6 ms G Vue 3.5 91.5 ms F AngularJS(2010) 24.4 ms I Solid 1.9 11.4 ms H Svelte 5 9.6 ms J Angular 22 6.7 ms E Knockout(2010) 5.2 ms (ブラウザが描く分) ≈ 118 ms シグナルの3つは 10 ms 前後。作りなおして比べる2つは 90 ms 台 点線の枠は第2回の2つ。描く時間はどれも同じくらいで、ブラウザの仕事

はっきり2つに分かれました。React と Vue は 90 ms 台、Svelte・Solid・Angular は 10 ms 前後で、2010年の Knockout(5.2 ms)と同じ側です。

2つの家族:作りなおして比べる/つながった所だけ

作りなおして比べる(仮想 DOM) 状態が変わる setRows・ref コンポーネントをもう一度 画面の形を全部作る 前回と比べて書く ちがう所だけ C React 19 99.6 ms G Vue 3.5 91.5 ms つながった所だけ(シグナル) 値が変わる $state・signal・store 読んでいる所を知っている ビルドのときに結ぶ そこだけ書く 比べる仕事がない H Svelte 5 9.6 ms I Solid 1.9 11.4 ms J Angular 22 6.7 ms E Knockout(2010) 5.2 ms どちらも「状態 → 画面」の片方向。数字は 5000 行で1文字の JS(実測)

React と Vue は、金額が1つ変わっても、コンポーネントをもう一度動かし、5000 行ぶんの画面の形を作って比べます。シグナルの家族は、その金額を読んでいる所(合計)を最初から知っているので、比べる仕事がありません。React が 2013年に「わかりやすさ」のために払った代金を、2020年代のシグナルが取りもどした、と見ることもできます。ただし、描く時間(100 ms あまり)はどれも同じです。5000 行の表を画面に出すこと自体が重い、というのは第2回と同じ結論です。

新井じゃあ、React をやめてシグナルにすれば……。

古井10 行の表なら、どれも 2 ms ほどで終わる。React には React の縮め方がある。それは第8回で測る。

八

ビルドと大きさ

端末の画面。node build.mjs で Vite 8 が vue 227 ms、svelte 183 ms、solid 177 ms でビルド。ls の結果は rows-solid.js 18583、rows-svelte.js 38401、rows-vue.js 68096 バイト。npx ng build は main.js 110.73 kB、転送の見込み 33.55 kB、3.192 秒
Vue・Svelte・Solid は Vite で、Angular は Angular CLI でビルドしました。どれも配る前に JavaScript になります(筆者がクラウド上の Linux で、2026年11月1日に撮影。ウインドウの上の帯は説明のために描き足したもの)
# 自分で書いた量と、ブラウザに届く大きさ(app.js と文字の分は、どれも同じなので除いた)
  書き方書いた行届く大きさgzip        ビルド
  G Vue 3.5       47        68,096 B      26,359 B    Vite 8.3  0.2 s
  H Svelte 5      44        38,401 B      15,032 B    Vite 8.3  0.2 s
  I Solid 1.9     54        18,583 B      7,232 B     Vite 8.3  0.2 s
  J Angular 22    73        110,729 B     37,223 B    Angular CLI 22  3.2〜4.4 s
  C React 19      59        226,513 B     70,123 B    esbuild 0.28  73 ms
# 書いた行は空行を除く(説明のコメントもふくむ)。J は型の宣言が 13 行ある。参考:第2回の Knockout は 70,680 B

届く大きさは、Solid の 18 KB から React の 226 KB まで、12 倍ちがいました。Svelte と Solid は、使った部品の分だけをはき出すので小さくなります。React は第1回のとおり、画面に書く係(react-dom)が大半です。ビルドは Vite なら 0.2 秒、Angular CLI でも数秒でした。第1回で「引きかえに増えるもの」と書いたビルドの手順は、いまはどの流派にもある、ふつうの手順になっています。

九

画面をサーバーで作る

ここまでの7つは、どれもブラウザの中で画面を作りました。最初に届く HTML はほとんど空で、JavaScript が届いて動くまで、明細の表は出ません。そこで、サーバーで HTML まで作って送る流れが戻ってきます。

  • 2016年 ── Next.js 1.0。React の画面を、サーバーで HTML にしてから送る(SSR)
  • 2023年 ── Next.js 13.4 の App Router。画面の部品ごとに、サーバーで動くかブラウザで動くかを分ける
  • 2024年 ── React 19 で Server Components が正式に。サーバーだけで動く部品は、ブラウザへ JavaScript を送らない

バックエンド編で作った Spring Boot のサーバーが HTML を返していた時代と、ブラウザで全部作る時代の、ちょうど間です。この連載では第12・13回で、Next.js の上で実際に測ります。

十

自分の PC で

どの流派も、ひな形を作るコマンドがあります。Node.js が入っていれば、次の1行でそれぞれの練習用プロジェクトが作れます。

# Vite のひな形(react・vue・svelte・solid などから選ぶ)
npm create vite@latest
# Angular のひな形
npx @angular/cli new my-app

同じ画面を2つの流派で書き、開発者ツールの Performance タブで1文字打つところを記録すると、この回の「2つの家族」のちがいが自分の目で見えます。黄色(Scripting)の長さを比べてください。

結 — 一枚まとめと、次回

  1. React(2013)は片方向を持ちこんだ。状態から画面を作りなおし、前回と比べてちがう所を書く。
  2. Vue・Angular・Svelte・Solid の最新版も、同じ8つの操作でずれ0、厳しい CSP でもエラー0。どれもビルドしてから配る。
  3. 5000 行で1文字:作りなおして比べる React・Vue は 90 ms 台、シグナルの Svelte・Solid・Angular は 10 ms 前後。描く時間はどれも 100 ms あまり。
  4. 届く大きさは Solid 18 KB 〜 React 226 KB。
  5. 2016年からは、画面をサーバーで作る流れも戻ってきた(Next.js、Server Components)。

新井歴史はわかりました。で、この連載はどれで書くんですか?

古井React だ。いちばん使われていて、Next.js でサーバーまで続いている。遅い所も、測ってから直せばいい。

古井次回は React の最初の一歩。Vite でひな形を作り、JSX とコンポーネントで、経費精算の画面を作りはじめる。

参考

記録は、筆者がクラウド上の Linux で、2026年11月1日に取ったものです。ページは練習用のサーバー(Node.js 22)から配り、Playwright で動かした Chromium 153.0.8010.12 で操作と測定をしました。G は Vue 3.5.44、H は Svelte 5.57.3、I は Solid 1.9.18 を Vite 8.3.5 で、J は Angular 22.2.4 を Angular CLI 22.2.5 でビルドしました。C は第1回の React 19.3.1 です。時間は、同じ欄に値を入れて input のイベントを送り、次の描画を待つことを 20 回くり返し、そのあいだの Chromium の記録から JavaScript(FunctionCall・RunMicrotasks・TimerFire など)と描く仕事(Layout・Paint など)の時間を重ならないように足して 20 で割り、3回の中央値をとりました。公開の年は npm の記録と参考の資料によります。ほかの PC では数字が変わります。

いにしえのフロントエンド入門 フレームワーク編 第3回 — 歴史②:React 以後 / 2026.11

← 記事一覧へ