Old-School Front-End · Frameworks / 2026 ・ No.02
古法前端入门 框架篇
第2讲 历史①:从 jQuery 到 MVC ── 2010 年的三种答案,Backbone・Knockout・AngularJS
发布
第 1 讲测到,jQuery 的页面要人记住“改哪里”,只是多显示一个件数就会对不上。2010 年,几乎同时出现了三种答案:Backbone、Knockout、AngularJS。
第 2 讲用这三者写同一张明细表,做和第 1 讲同样的操作来比较:会不会对不上、改写了什么、行多时怎样,以及在如今严格的 CSP 下能不能运行。
2010 年,页面变大了
新井React 之前,不是只有 jQuery 吗?
古井2010 年出了三个。而且每一个都还有在运行的应用。
新井化石啊。
古井是不是化石,测了再说。
全 15 讲的路线图(当前位置:第 2 讲)
那时,不重新加载页面、只在页面里运行的应用越来越多,页面的 JavaScript 从几百行变成几千行。jQuery 作为“找到页面部件并改写”的工具已经够用,但真正的值放在哪里、何时反映到页面上,全交给写代码的人。第 1 讲测到的“件数对不上”,正是这个烦恼本身。
三个流派及其前后(年表)
三者都写了第 1 讲的明细表(添加・删除行、金额、币种、合计,以及件数)。计算仍用之前的 app.js。为了符合那个时代的写法,服务器先去掉 CSP 等防护头运行,CSP 在第八节另外试。
Backbone:把值放进 Model
Backbone(写法 D)是和 Underscore 同一位作者的小型库。思路是 MVC:真正的值放在 Model(1 行)和 Collection(行的集合)里,页面部件由 View 负责。值一变,Model 就发出事件,View 听到后改写页面。
// 写法 D:Backbone 1.6(2010 年〜)
// 真正的值放在 Model(1 行)和 Collection(行的集合)里。值一变,Model 就发出事件。
// 改写画面的是 View。哪个事件改写哪里,要自己写在这里。
const Row = Backbone.Model.extend({ defaults: { item: '', amount: '', currency: 'JPY' } });
const rows = new Backbone.Collection([], { model: Row });
const settings = new Backbone.Model({ rate: null, date: '' }); // 汇率
/** 1 行的 View:负责表格的 1 行(tr) */
const RowView = Backbone.View.extend({
tagName: 'tr',
template: _.template($('#row-template').html()), // 把模板文字变成函数
events: {
'input [name="item"]': 'edit',
'input [name="amount"]': 'edit',
'change [name="currency"]': 'edit',
'click [data-action="remove"]': 'drop',
},
edit(event) { this.model.set(event.target.name, event.target.value); }, // 画面 → Model,要自己写
drop() { rows.remove(this.model); },
render() { this.$el.html(this.template(this.model.toJSON())); return this; },
});
/** 整个画面的 View */
const AppView = Backbone.View.extend({
el: '#page',
events: { 'click #add-row': 'add' },
initialize() {
this.listenTo(rows, 'add', this.addOne);
this.listenTo(rows, 'remove', this.removeOne);
this.listenTo(rows, 'update change', this.renderSummary); // 行增加了・减少了・值变了
this.listenTo(settings, 'change', this.renderSummary);
},
add() { … }, // rows.add({}),光标移到新行
addOne(row) { … }, // 做出 RowView 加到表里
removeOne(row) { … },
/** 重新显示合计・件数・按钮・汇率 */
renderSummary() {
const total = …; // 用 rows.each 一行行相加
this.$('#total').text(formatYen(total));
this.$('#receipt-note').prop('hidden', !needsReceipt(total));
this.$('#count').text(ROWS_TEXT.count(rows.length)); // ★ 件数
this.$('#add-row').prop('disabled', rows.length >= MAX_ROWS);
this.$('#rate-note').text(rate === null ? '' : ROWS_TEXT.rate(rate, settings.get('date')));
},
});
显示件数的 1 行(★)和合计一起写在 renderSummary 里。和第 1 讲 jQuery 的放法一样,但这次没有对不上。调用 renderSummary 的不是按钮,而是 Collection 的 update(行增加・减少)和 change(值变化)。不管从哪个操作来,只要值变了,就发出同样的事件。第 1 讲的“操作 × 函数”表,变成了“值 × 函数”表。
不过,改写页面的仍是自己。this.$('#total').text(…) 和 jQuery 时一样,页面 → Model 的方向(edit)也是自己写的。
Knockout:把值和页面绑在一起
Knockout(写法 E)采用 MVVM 的形式。值放进 observable(可监视的容器),在 HTML 的 data-bind 里写明“这个部件和哪个值相连”。
# rows-ko.html(节选):在 HTML 里写明和哪个值相连
<h1 …>费用明细 <span id="count" data-bind="text: countText"></span></h1>
<tbody data-bind="foreach: rows">
<tr>
<td><input … data-bind="textInput: item"></td>
<td><input … data-bind="textInput: amount"></td>
<td><select … data-bind="value: currency">…</select></td>
<td><button … data-bind="click: $root.remove">…</button></td>
</tr>
</tbody>
<output id="total" … data-bind="text: totalText"></output>
# rows-ko.js(节选)
function RowModel() {
this.item = ko.observable('');
this.amount = ko.observable('');
this.currency = ko.observable('JPY');
}
function RowsModel() {
this.rows = ko.observableArray([new RowModel()]);
this.rate = ko.observable(null);
// 合计:里面读过的 observable(行数・金额・币种・汇率)一变,就重新计算
this.total = ko.pureComputed(() => {
const rate = this.rate();
const items = [];
for (const row of this.rows()) {
const usd = row.currency() === 'USD';
const amount = usd ? toCents(row.amount()) : toAmount(row.amount());
if (amount === null || Number.isNaN(amount)) continue;
if (!usd) items.push({ amount });
else if (rate !== null) items.push({ amount: centsToYen(amount, rate.rate) });
}
return calcTotal(items);
});
this.totalText = ko.pureComputed(() => formatYen(this.total()));
this.countText = ko.pureComputed(() => ROWS_TEXT.count(this.rows().length)); // ★ 件数
textInput: amount 是双向连接。在栏里输入,值就变;改变值,栏也变。合计用 pureComputed,Knockout 记住里面读过的 observable(行数・金额・币种・汇率),任何一个变了就重新计算。件数(★)也只要 1 行。JavaScript 里一行改写页面的代码都没有。
新井JavaScript 才 37 行!是 jQuery 的一半。
古井但是 HTML 里写了 10 处表达式。两边都算“写了的东西”。
AngularJS:监视普通的值
AngularJS(写法 F)由 Google 的开发者们打造,值只是放进普通对象里。HTML 的 ng-model 把值和输入栏双向连接,{{ }} 把值作为文字显示。
# rows-ng.html(节选):用 ng-model 连接值和栏,用 {{ }} 连接值和文字
<main … ng-app="rows">
<div ng-controller="RowsController as vm">
<h1 …>费用明细 <span id="count">{{ vm.countText() }}</span></h1>
<tbody>
<tr ng-repeat="r in vm.rows track by r.id">
<td><input … ng-model="r.item"></td>
<td><input … ng-model="r.amount"></td>
<td><select … ng-model="r.currency">…</select></td>
<td><button … ng-click="vm.remove(r)">…</button></td>
</tr>
</tbody>
<output id="total" …>{{ vm.totalText() }}</output>
# rows-ng.js(节选):值只是放进普通对象里
angular.module('rows', []).controller('RowsController', ['$http', function ($http) {
const vm = this;
let nextId = 1;
const newRow = () => ({ id: nextId++, item: '', amount: '', currency: 'JPY' });
vm.max = 10;
vm.rows = [newRow()];
vm.rate = null;
vm.total = () => { … }; // 把 vm.rows 一行行相加(和 D・E 同样的计算)
vm.totalText = () => formatYen(vm.total());
vm.needsReceipt = () => needsReceipt(vm.total());
vm.countText = () => ROWS_TEXT.count(vm.rows.length); // ★ 件数
vm.rateText = () => (vm.rate ? ROWS_TEXT.rate(vm.rate.rate, vm.rate.date) : '');
没有 observable 那样的容器,怎么知道变了呢?每次点击或输入,AngularJS 都把页面上写的表达式(监视,watcher)全部重新计算,和上一次的值比较。这叫脏检查。只把不同的地方写到页面上。
三个流派:从值的位置到页面
同样的 8 个操作
用 Playwright 做和第 1 讲同样的 8 个操作(输入金额、加行、改成美元、删行),每次都查看页面。
# 和第 1 讲同样的 8 个操作(D・E・F 一开始就写好件数)。为了比较,也放上 A 和 C 写法件数不对合计不对添加后的光标删除后 D Backbone 0 / 8 0 / 8 新行的内容“添加一行” E Knockout 0 / 8 0 / 8 “添加一行”BODY F AngularJS 0 / 8 0 / 8 “添加一行”BODY A jQuery 3 / 8 0 / 8 新行的内容“添加一行” C React 0 / 8 0 / 8 “添加一行”BODY # D・E・F 三种语言都是 0 次。光标只有 D 和 A 一样(因为是自己写的)
三者的件数和合计一次也没有对不上(日语・英语页面也一样)。也就是说,到 2010 年,第 1 讲的烦恼已经有了答案。
光标的去处则各不相同。D 和 jQuery 一样,加行后移到新行,删行后移到“添加一行”。这不是 Backbone 做的,而是自己写的。E 和 F 与 React(C)一样,不写就什么也不做。
每个字改写了什么
在 3 行的表里逐字输入 14720,每次用 MutationObserver 数页面的变化(和第 1 讲的测法相同)。
# 在 3 行的表里,往第 1 行金额输入 1・4・7・2・0 时,每个字的画面变化(MutationObserver) 写法变化记录内容 D Backbone 7 替换合计・件数・汇率的文字,重新设置提示的 hidden(即使没变) E Knockout 1 只有合计的文字 F AngularJS 3 → 1 只有合计的文字(只在第 1 个字时,栏的 class 加上 ng-dirty,所以是 3) A jQuery 5 替换合计和件数的文字 C React 23 其中 21 条是 React 用同样的值重设 name・type 的内部步骤
差别很明显。D 每次连没变的件数和汇率文字也替换掉。因为 renderSummary 不知道“变了什么”,全部重新显示。E 只有合计的文字 1 处。Knockout 记得哪个页面部件绑定了哪个值,所以只写变化的值所连之处。F 也只有合计的文字,不过这是比较全部监视之后的结果。
行多的时候
把行数设成 10・1000・5000,测量输入 1 个字时页面 JavaScript 工作的时间,以及从打开页面到行齐全的时间。为了比较,第 1 讲的 A(jQuery)和 C(React)也在同一天重测。
# 输入 1 个字时页面 JavaScript 工作的时间,以及打开所需时间(3 次的中位数;每次是 20 次的中位数) 写法每个字的 JS(ms)打开到行齐全(ms) 10 1000 5000 1000 5000 D Backbone 0.5 0.8 1.6 741 3,016 E Knockout 0.4 1.2 3.9 817 2,968 F AngularJS 1.0 2.9 16.4 970 3,931 C React 1.8 19.0 83.0 668 2,483 A jQuery 0.7 11.0 — 9,716 — (没有 JS)只放入值再绘制,1000 行 28.5 ms,5000 行 131.6 ms(浏览器的工作) # F 的监视(watcher)10 行 76 个,1000 行 7,006 个,5000 行 35,006 个。每行 7 个 # A 每加一行就重设所有行的名称,所以没测 5000 行
在 5000 行时输入 1 个字,JavaScript 工作了多少毫秒
F 的监视每行 7 个(3 个输入栏和 4 个朗读名称)。5000 行时达到 35,006 个,每个字都要全部比较,所以用了 16.4 ms。即便如此,它和 E 的 3.9 ms、D 的 1.6 ms 一样,都比 React 的 83 ms 快。因为 React 每输入一次,都要重建 5000 行页面的样子再比较(第 8 讲会缩小这里)。
还有一点:5000 行时,只是放入值再绘制就要约 132 ms。这是浏览器的工作,哪种写法都一样。行多的页面,最有效的不是工具,而是先减少行数。另外,A 每加一行就重设所有行的朗读名称,打开 1000 行要 9.7 秒。
在如今的 CSP 下
到这里为止都去掉了防护头。加上前端篇第 15 讲的 CSP(script-src 'self'、style-src 'self')打开,结果如下。
# 保持前端篇第 15 讲的 CSP(script-src 'self'、style-src 'self')打开,在第 1 行输入 14720 写法错误结果 D Backbone 1 一行也不出来。_.template 无法从文字生成函数 E Knockout 1 什么都没连上(Unable to parse bindings) F AngularJS 1 能用,但无法加入 ng-hide 的 CSS,提示一直显示 F + ng-csp 0 加上 ng-csp 和 angular-csp.css 后,没有错误 A / C 0 能用
D 中生成行模板的 _.template 想从文字生成函数,被拦下了(Backbone 本体里找不到这样的地方)。E 在把 data-bind 的表达式变成函数时停住,什么都没连上。2010 年前后,把写在 HTML 或文字里的表达式当场变成 JavaScript 是常见做法,那是 CSP 普及之前的写法。
F 能运行,但无法用 <style> 加入 ng-hide 用的 CSS,不到 3 万日元提示却一直显示。AngularJS 预见了 CSP,准备了 ng-csp 和单独文件 angular-csp.css,加上之后没有错误地运行了。这和前端篇第 15 讲里 Vue 不构建就无法运行,是同一道墙。
大小,以及各自的现在
# 自己写的量和送到浏览器的大小(app.js 和文字部分都一样,所以不计) 写法JS 行数HTML 绑定处送达大小gzip D Backbone 77 0 127,724 B 44,950 B E Knockout 37 10 70,680 B 26,300 B F AngularJS 35 17 179,261 B 62,828 B A jQuery 73 0 81,575 B 28,787 B C React 59 — 226,513 B 70,113 B # JS 行数不含空行(含说明注释)。HTML 绑定处是 data-bind・ng-*・{{ }} 的个数。D 的明细:jQuery 78,748、Underscore 19,668、Backbone 25,690
E 和 F 的 JavaScript 减半,代价是 HTML 里多了表达式。D 还需要 jQuery 和 Underscore,三者合计 128 KB。F 一个就有 179 KB,但都比 React(226 KB)小。
名称首次发布最新现在 Backbone 2010 年 10 月1.6.1 (2025 年 4 月)仍在维护 Knockout 2010 年 7 月3.5.3 (2026 年 3 月)仍在维护 AngularJS 2010 年(1.0 是 2012 年 6 月)1.8.3 (2022 年 4 月)2021 年 12 月 31 日停止支持,npm 上也标为弃用 # 最新版本和日期依据 npm 的记录(2026 年 11 月 1 日确认)
AngularJS 于 2021 年 12 月 31 日停止支持,接替它的是重新打造、完全不同的 Angular。Backbone 和 Knockout 至今仍有新版本发布。停下的和延续的。接手老旧的业务应用时,请先确认它是用哪个写的。
在自己的电脑上
手头如果有 AngularJS 的页面,可以在开发者工具的控制台里数监视的数量(从带 ng-app 的部件开始数)。
// 在 AngularJS 页面上:监视(watcher)的数量
angular.element(document.querySelector('[ng-app]')).injector().get('$rootScope').$$watchersCount
在 CSP 下能否从文字生成函数,在任何页面上都可以用下面 1 行来试。在严格 CSP 的页面上会报错。
// 能否从文字生成函数(CSP 不允许 'unsafe-eval' 时会报错)
new Function('return 1')()
以 $$ 开头的名字是 AngularJS 内部用的,请只用于调查。
结 — 一页总结与下一讲
- 2010 年,Backbone・Knockout・AngularJS 几乎同时出现。三者都把真正的值放在页面之外。
- Backbone 用事件通知值的变化,页面自己写。Knockout 把值和页面绑在一起,AngularJS 监视全部并比较。
- 同样的 8 个操作中,件数不对三者都是 0 次。每个字的页面变化是 7・1・1。
- 5000 行时每个字 1.6・3.9・16.4 ms,比 React 的 83 ms 快。光绘制就要 132 ms 是浏览器的部分。
- 在严格的 CSP 下,从文字生成表达式的写法会停住。AngularJS 用 ng-csp 能运行,2021 年停止支持。
新井又快、又不会对不上、还小。为什么换成 React 了?
古井这张表只是 10 行的练习。页面变成成百上千个部件时,双向连接让人很难追踪变化从哪里来。
新井连得太多,缠在一起了?
古井下一讲是历史②。从 React 带来“单向”开始,到 Vue、Angular、Svelte、Solid,再到在服务器上生成页面。
参考
- Knockout — Introduction
- AngularJS — ngCsp
- AngularJS — Scopes($digest と watcher)
- Angular Blog — Discontinued Long Term Support for AngularJS
- Wikipedia — Knockout (web framework)
- versionlog — AngularJS 1.0
- Web Design Museum — React 2013
- MDN — CSP: script-src(unsafe-eval)
记录是笔者于 2026 年 11 月 1 日在云端 Linux 上取得的。页面由练习用服务器(Node.js 22)提供,用 Playwright 驱动的 Chromium 153.0.8010.12 进行操作和测量。D 是 jQuery 4.0.0・Underscore 1.13.8・Backbone 1.6.1,E 是 Knockout 3.5.3,F 是 AngularJS 1.8.3,外观都用 Bootstrap 5.3.8。时间是在同一个栏里放入值、发送 input 事件,测量到返回为止(JavaScript 的时间)和到下一次绘制之后,各测 25 次去掉前 5 次取 20 次的中位数,再重复 3 次取中位数。发布年份依据各项目的记录和参考资料,最新版本依据 npm 的记录。在其他电脑上数字会不同。
古法前端入门 框架篇 第2讲 — 历史①:从 jQuery 到 MVC / 2026.11