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

古法前端入门 框架篇

第2讲 历史①:从 jQuery 到 MVC ── 2010 年的三种答案,Backbone・Knockout・AngularJS

发布

3 个浏览器画面。都是“费用明细”表,做完和第 1 讲同样的 8 个操作之后。左上是写法 D(Backbone 1.6),右上是 E(Knockout 3.5),下方是 F(AngularJS 1.8)。三者都是明细 3 条,金额 14720 和 100(美元),一行空行,合计 29,843 日元
用 2010 年出现的三个流派写第 1 讲的明细表,做同样的操作。三者的件数和合计都正确(笔者于 2026 年 11 月 1 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)

第 1 讲测到,jQuery 的页面要人记住“改哪里”,只是多显示一个件数就会对不上。2010 年,几乎同时出现了三种答案:Backbone、Knockout、AngularJS。
第 2 讲用这三者写同一张明细表,做和第 1 讲同样的操作来比较:会不会对不上、改写了什么、行多时怎样,以及在如今严格的 CSP 下能不能运行。

一

2010 年,页面变大了

新井React 之前,不是只有 jQuery 吗?

古井2010 年出了三个。而且每一个都还有在运行的应用。

新井化石啊。

古井是不是化石,测了再说。

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

1 为什么要框架 2 jQuery→MVC 3 React 以后 4 React 第一步 5 状态与重绘 6 表单 7 获取数据 8 重绘的代价 9 状态放哪里 10 页面切换 11 乐观更新 12 Server Comp. 13 缓存 14 测试 15 发布与选择 当前位置 缘由与历史 React 基础 速度与位置 页面与服务器 Next.js 收尾

那时,不重新加载页面、只在页面里运行的应用越来越多,页面的 JavaScript 从几百行变成几千行。jQuery 作为“找到页面部件并改写”的工具已经够用,但真正的值放在哪里、何时反映到页面上,全交给写代码的人。第 1 讲测到的“件数对不上”,正是这个烦恼本身。

三个流派及其前后(年表)

三个流派及其前后(年表) 2006 2008 2010 2012 2014 2016 2018 2020 2022 2024 2026 jQuery 2006 4.0 Knockout 1.0(2010.7) 3.5.3 Backbone 0.1(2010.10) 1.6.1 AngularJS 1.0(2012.6) 停止支持 React 2013.5 → 第 3 讲 虚线表示停止支持后仍在运行的应用。2010 年,三种答案几乎同时出现

三者都写了第 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)全部重新计算,和上一次的值比较。这叫脏检查。只把不同的地方写到页面上。

三个流派:从值的位置到页面

D Backbone(MVC) 放进 Model set 自己写 事件 change・update View 的函数 画面自己写 改哪里由人决定 何时写由工具通知 E Knockout(MVVM) observable 与画面双向 computed 记住读过的值 只改相连处 data-bind 工具知道哪里会变 细粒度相连 F AngularJS 普通的值 ng-model 双向 脏检查 比较全部监视 只改不同处 {{ }}・ng-* 变了什么要查了才知道 监视随行数增加 用 jQuery 时,“改哪里”全靠人记住。三者都把其中一部分交给了工具 交出去的多少和方式不同。这种不同体现在速度、写法和 CSP 的结果上
五

同样的 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 行
终端画面。node keys2.js 5000 的结果。rows-react.html 的 JS 84.2 ms、绘制完 223.2 ms,rows-bb.html 1.5 ms・133.7 ms,rows-ko.html 3.6 ms・105.0 ms,rows-ng.html 16.3 ms・149.0 ms,watchers 35006
在 5000 行的表里每输入一个字所需的时间,只测了 1 次的画面(正文的数字是 3 次的中位数)。AngularJS 的监视有 35,006 个(笔者于 2026 年 11 月 1 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)

在 5000 行时输入 1 个字,JavaScript 工作了多少毫秒

在 5000 行的表里输入 1 个字,JavaScript 工作了多少毫秒(实测) D Backbone 1.6 ms E Knockout 3.9 ms F AngularJS 16.4 ms C React(第 1 讲) 83.0 ms (浏览器绘制部分) 132 ms 2010 年的三者比 React 快。React 每输入一次都把整张表重新计算 如何缩小这个差距在第 8 讲(重绘的代价)。即便如此 React 仍被选中的原因在第 3 讲

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')打开,结果如下。

3 个浏览器画面。保持前端篇第 15 讲的严格 CSP 打开,输入 14720 之后。D Backbone 只有表头,一行也没有,合计是 0。E Knockout 有一行并输入了 14720,但件数和合计都是空的,还显示着收据提示。F AngularJS 显示明细 1 条、合计 14,720 日元,能用,但不到 3 万日元收据提示却一直显示
保持严格的 CSP 打开。D 不出行,E 什么都没连上,F 能用,但本该隐藏的提示一直显示(笔者于 2026 年 11 月 1 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)
# 保持前端篇第 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 内部用的,请只用于调查。

结 — 一页总结与下一讲

  1. 2010 年,Backbone・Knockout・AngularJS 几乎同时出现。三者都把真正的值放在页面之外。
  2. Backbone 用事件通知值的变化,页面自己写。Knockout 把值和页面绑在一起,AngularJS 监视全部并比较。
  3. 同样的 8 个操作中,件数不对三者都是 0 次。每个字的页面变化是 7・1・1。
  4. 5000 行时每个字 1.6・3.9・16.4 ms,比 React 的 83 ms 快。光绘制就要 132 ms 是浏览器的部分。
  5. 在严格的 CSP 下,从文字生成表达式的写法会停住。AngularJS 用 ng-csp 能运行,2021 年停止支持。

新井又快、又不会对不上、还小。为什么换成 React 了?

古井这张表只是 10 行的练习。页面变成成百上千个部件时,双向连接让人很难追踪变化从哪里来。

新井连得太多,缠在一起了?

古井下一讲是历史②。从 React 带来“单向”开始,到 Vue、Angular、Svelte、Solid,再到在服务器上生成页面。

参考

记录是笔者于 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

← 返回文章列表