Old-School Front-End · Frameworks / 2026 ・ No.02
いにしえのフロントエンド入門 フレームワーク編
第2回 歴史①:jQuery から MVC へ ── 2010年の3つの答え、Backbone・Knockout・AngularJS
公開
第1回では、jQuery の画面が「どこを直すか」を人に覚えさせていて、件数を1つ出すだけでずれることを測りました。この悩みに、2010年、3つの答えがほぼ同時に出ます。Backbone、Knockout、AngularJS です。
第2回は、同じ明細の表をこの3つで書き、第1回と同じ操作をして比べます。ずれるか、何が書きかわるか、行が多いとどうか、そして、いまの厳しい CSP の下で動くか。
2010年、画面が大きくなった
新井React の前って、jQuery しかなかったんじゃないんですか?
古井2010年に、3つ出ている。しかも、どれも今でも動いているアプリがある。
新井化石ですね。
古井化石かどうか、測ってから決めよう。
全15回の路線図(いまここ:第2回)
このころ、ページを読みこみなおさずに画面の中だけで動くアプリが増え、画面の JavaScript は数百行から数千行になっていきました。jQuery は「画面の部品を探して書きかえる」道具としては十分でしたが、本当の値をどこに置き、いつ画面に反映するかは、書く人に任されていました。第1回で測った「件数のずれ」は、その悩みそのものです。
3つの流派と、その前後(年表)
3つとも、第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 で1行ずつ足す
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 で、中で読んだ observable(行の数・金額・通貨・レート)を Knockout が覚えておき、どれかが変われば計算しなおします。件数(★)も1行。画面を書きかえる行は、JavaScript には1つもありません。
新井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 を1行ずつ足す(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)を全部計算しなおし、前回の値と比べます。これをダーティチェックと呼びます。ちがっていたところだけを、画面に書きます。
3つの流派:値の置き場所から画面まで
同じ8つの操作
第1回と同じ8つの操作(金額を打つ、行を足す、ドルにする、行を消す)を Playwright でして、そのたびに画面を見ました。
# 第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 は 3 言語とも 0 回。カーソルは D だけが A と同じ(自分で書いたから)
3つとも、件数も合計も一度もずれませんでした(中国語・英語のページでも同じ)。2010年の時点で、第1回の悩みには答えが出ていたことになります。
カーソルの行き先は分かれました。D は jQuery と同じく、行を足すと新しい行へ、消すと「行を追加」へ移ります。これは Backbone がしたのではなく、自分で書いたからです。E と F は React(C)と同じで、何も書かなければ何もしません。
1文字で何が書きかわるか
3行の表で 14720 と1文字ずつ打ち、そのたびに画面の変化を MutationObserver で数えました(第1回と同じ測り方)。
# 3行の表で、1行目の金額に 1・4・7・2・0 と打ったとき、1文字ごとの画面の変化(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回の中央値。1回は 20 回の中央値) 書き方1文字の 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。1行に 7 つ # A は行を1つ足すたびに全部の行の名前を付けなおすので、5000 行は測っていない
5000 行で1文字打つと、JavaScript は何ミリ秒働いたか
F の見張りは1行に7つ(3つの入力欄と、4つの読み上げの名前)。5000 行で 35,006 個になり、1文字ごとにそれを全部比べるので 16.4 ms かかりました。それでも E の 3.9 ms、D の 1.6 ms とともに、React の 83 ms より速い。React は打つたびに、5000 行ぶんの画面の形を作りなおして比べるからです(第8回で、ここを縮めます)。
もう1つ、5000 行では値を入れて描くだけで約 132 ms かかりました。これはブラウザの仕事で、どの書き方でも同じです。行が多い画面で効くのは、道具よりも、まず行の数です。なお A は行を1つ足すたびに全部の行の読み上げ名をつけなおすので、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 行が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 は動きましたが、ng-hide のための CSS を <style> で足せず、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 も要るので、3つ合わせて 128 KB。F は1つで 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 は、いまも新しい版が出ています。止まったものと、続いているもの。古い業務アプリを預かったときは、まずどれで書かれているかを確かめてください。
自分の PC で
手元に 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つの操作で、件数のずれは3つとも0回。1文字ごとの画面の変化は 7・1・1。
- 5000 行で1文字 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)
記録は、筆者がクラウド上の Linux で、2026年11月1日に取ったものです。ページは練習用のサーバー(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 の記録によります。ほかの PC では数字が変わります。
いにしえのフロントエンド入門 フレームワーク編 第2回 — 歴史①:jQuery から MVC へ / 2026.11