Old-School Front-End · Frameworks / 2026 ・ No.03
古法前端入门 框架篇
第3讲 历史②:React 以后 ── 重建再比较的流派,与信号的流派
发布
第 2 讲测到,2010 年的三个流派件数不会对不上,而且比 React 还快。即便如此,2013 年出现的 React 还是流行开来,之后又接连出现了 Vue、Angular、Svelte、Solid 等新答案。
第 3 讲用这四者的最新版写同一张明细表,和第 1 讲以来的 React 放在一起测量。最后也会谈到在服务器上生成画面的潮流。
React 带来了什么
新井上一讲 React 不是最慢吗?为什么它赢了?
古井React 卖的不是速度,而是好懂:“状态一定,画面就只有一种”。
全 15 讲的路线图(当前位置:第 3 讲)
第 2 讲的 Knockout 和 AngularJS 把值和画面双向连接。画面一大,就很难追踪改变某个值的是输入栏、别的计算,还是另一个监视。React 把方向定为单向:只能通过 setRows 这样固定的入口改变状态,画面每次都从状态重建,与上次比较,只写不同之处(第 1 讲的写法 C)。用来比较的画面样子的副本,叫作虚拟 DOM。
React 以后(年表)
这种思路被广泛接受,之后的流派也都以“状态 → 画面”的单向为基础。不同的是如何决定改写哪里。用第 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 到返回)的测法不同
在 5000 行时输入 1 个字,JavaScript 工作了多少毫秒
结果明显分成两组。React 和 Vue 在 90 多 ms,Svelte・Solid・Angular 在 10 ms 左右,和 2010 年的 Knockout(5.2 ms)同一边。
两个家族:重建再比较/只改相连处
即使只变了一个金额,React 和 Vue 也会重新运行组件,生成 5000 行的画面样子再比较。信号家族一开始就知道读取这个金额的地方(合计),所以没有比较的工作。可以看作:React 在 2013 年为了“好懂”付出的代价,被 2020 年代的信号取回来了。不过,绘制时间(100 多 ms)都一样。把 5000 行的表显示出来本身就很重,这和第 2 讲的结论相同。
新井那就别用 React,改用信号……。
古井10 行的表,哪个都是 2 ms 左右就完事了。React 有 React 的缩短方法,这在第 8 讲测。
构建与大小
# 自己写的量和送到浏览器的大小(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)的长度。
结 — 一页总结与下一讲
- React(2013)带来了单向:从状态重建画面,与上次比较,写入不同之处。
- Vue・Angular・Svelte・Solid 的最新版在同样 8 个操作中不对不上 0 次,严格的 CSP 下也错误 0。都构建后再发布。
- 5000 行时每个字:重建再比较的 React・Vue 在 90 多 ms,信号的 Svelte・Solid・Angular 在 10 ms 左右。绘制时间都是 100 多 ms。
- 送达大小从 Solid 18 KB 到 React 226 KB。
- 从 2016 年起,在服务器上生成画面的潮流也回来了(Next.js、Server Components)。
新井历史明白了。那这个连载用哪个写?
古井React。用得最多,而且通过 Next.js 一直延伸到服务器。慢的地方,测了再改就好。
古井下一讲是 React 的第一步。用 Vite 生成模板,用 JSX 和组件开始做费用报销的画面。
参考
- React — Preserving and Resetting State
- Vue — Rendering Mechanism
- Svelte — What are runes?
- Solid — Intro to reactivity
- Angular — Signals
- InfoQ — Angular 21 Released(zoneless が既定に)
- React Blog — React v19
- Chrome DevTools — Performance features reference
记录是笔者于 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