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)。四者都是明细 3 条,金额 14720 和 100(美元),一行空行,合计 29,843 日元
用 React 以后的四种写同一张明细表,保持严格的 CSP 做同样的操作。四者的件数和合计都正确(笔者于 2026 年 11 月 1 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)

第 2 讲测到,2010 年的三个流派件数不会对不上,而且比 React 还快。即便如此,2013 年出现的 React 还是流行开来,之后又接连出现了 Vue、Angular、Svelte、Solid 等新答案。
第 3 讲用这四者的最新版写同一张明细表,和第 1 讲以来的 React 放在一起测量。最后也会谈到在服务器上生成画面的潮流。

一

React 带来了什么

新井上一讲 React 不是最慢吗?为什么它赢了?

古井React 卖的不是速度,而是好懂:“状态一定,画面就只有一种”。

全 15 讲的路线图(当前位置:第 3 讲)

1 为什么要框架 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 Hooks 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 年代转向按值修改的“信号”,以及服务器

这种思路被广泛接受,之后的流派也都以“状态 → 画面”的单向为基础。不同的是如何决定改写哪里。用第 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 区分行。因为有类型声明,在四者中行数最多。

四

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()) 这样当成普通数组来写,画面也会跟上。四者中最短,44 行。

五

Solid:只运行一次的组件

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(信号),这里把这三者称为信号家族。

六

同样的 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
# 三种语言都一样。都是构建后再发布,所以不从文字生成函数

四者的件数和合计一次也没有对不上,在严格的 CSP 下错误也是 0(日语・英语页面也一样)。和第 2 讲的 Backbone 模板、Knockout 不同,模板在构建时已经变成 JavaScript,所以浏览器里不从文字生成函数。光标方面,都是不写就什么也不做。

# 在 3 行的表里,往第 1 行金额输入 1・4・7・2・0 时,每个字的画面变化(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 的内部步骤

每个字改写的都只有合计的文字(Solid 还重设了提示的 hidden)。只有 React 包含 21 次用同样的值重设输入栏属性的步骤。传到画面的变化,哪种写法都差不多。差别体现在到达那里之前JavaScript 的工作量上。

七

5000 行时,两个家族

把行数设为 10・1000・5000,测量输入 1 个字时 JavaScript 工作的时间和浏览器绘制的时间。Vue・Svelte・Angular 会把画面改写稍微延后,所以第 2 讲的测法(发送 input 到返回)不够。这次从 Chromium 的记录(tracing)中把 20 个字的时间相加。第 2 讲的 Knockout 和 AngularJS 也用同样的方法重测了。

# 把行数设为 10・1000・5000,每输入 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 次的画面(正文的数字是 3 次的中位数)(笔者于 2026 年 11 月 1 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)

在 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 信号的三者在 10 ms 左右。重建再比较的两者在 90 多 ms 虚线框是第 2 讲的两个。绘制时间都差不多,是浏览器的工作

结果明显分成两组。React 和 Vue 在 90 多 ms,Svelte・Solid・Angular 在 10 ms 左右,和 2010 年的 Knockout(5.2 ms)同一边。

两个家族:重建再比较/只改相连处

重建再比较(虚拟 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 个字的 JavaScript(实测)

即使只变了一个金额,React 和 Vue 也会重新运行组件,生成 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(笔者于 2026 年 11 月 1 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)
# 自己写的量和送到浏览器的大小(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 讲说的“作为交换而增加的”构建步骤,如今已是每个流派都有的普通步骤。

九

在服务器上生成画面

到这里的七种,都是在浏览器里生成画面。最先送达的 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 上实际测量。

十

在自己的电脑上

每个流派都有生成模板的命令。装好 Node.js,用下面 1 行就能做出各自的练习项目。

# Vite 的模板(从 react・vue・svelte・solid 等中选)
npm create vite@latest
# Angular 的模板
npx @angular/cli new my-app

用两个流派写同一个画面,在开发者工具的 Performance 标签里记录输入一个字的过程,就能亲眼看到这一讲“两个家族”的不同。请比较黄色(Scripting)的长度。

结 — 一页总结与下一讲

  1. React(2013)带来了单向:从状态重建画面,与上次比较,写入不同之处。
  2. Vue・Angular・Svelte・Solid 的最新版在同样 8 个操作中不对不上 0 次,严格的 CSP 下也错误 0。都构建后再发布。
  3. 5000 行时每个字:重建再比较的 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 和组件开始做费用报销的画面。

参考

记录是笔者于 2026 年 11 月 1 日在云端 Linux 上取得的。页面由练习用服务器(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 的记录和参考资料。在其他电脑上数字会不同。

古法前端入门 框架篇 第3讲 — 历史②:React 以后 / 2026.11

← 返回文章列表