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

Old-School Front-End: Frameworks

Part 2: History I, from jQuery to MVC — 2010's three answers: Backbone, Knockout, AngularJS

Published

Three browser windows, each showing the Expense line items table after the same eight actions as part 1. Top left is version D (Backbone 1.6), top right E (Knockout 3.5), bottom F (AngularJS 1.8). All three show 3 line(s), amounts 14720 and 100 (USD), one empty row, and a total of ¥29,843
Part 1's items table written in the three schools that appeared in 2010, given the same actions. All three get the count and total right. (Captured by the author on a cloud Linux machine, 1 November 2026. The bar above each window was drawn in for explanation.)

Part 1 measured how a jQuery screen makes a person remember what to fix, and how showing just one more number made it drift. In 2010 three answers to that problem arrived almost together: Backbone, Knockout and AngularJS.
Part 2 writes the same items table in all three and repeats part 1's actions. Does it drift, what gets rewritten, what happens with many rows, and does it run under today's strict CSP?

1

2010: screens got bigger

AraiBefore React there was only jQuery, right?

FuruiThree came out in 2010. And every one of them still has apps running on it.

AraiFossils, then.

FuruiLet's measure before we decide that.

A route map of all 15 parts (you are here: part 2)

1 Why FWs 2 jQuery→MVC 3 After React 4 First React 5 State 6 Forms 7 Fetching 8 Render cost 9 Where state 10 Routing 11 Optimistic 12 Server Comp. 13 Caching 14 Testing 15 Ship & choose You are here Why & history React basics Speed & state Pages & server Next.js Finishing

Around then, more apps ran entirely inside the page without reloading, and their JavaScript grew from hundreds of lines to thousands. jQuery was enough for finding parts of the screen and rewriting them, but where the real values live, and when they reach the screen, was left to the author. Part 1's drifting count was exactly that problem.

The three schools, and before and after (timeline)

The three schools, and before and after (timeline) 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) support ends React 2013.5 → part 3 Dotted: still running after support ended. Three answers came in 2010

All three got part 1's items table (adding and removing rows, amount, currency, total, and the count). The sums are the same app.js as before. To match how things were written then, the server ran without CSP or other security headers; the CSP comes separately in section 8.

2

Backbone: values in Models

Backbone (version D) is a small library by the same author as Underscore. Its idea is MVC: the real values live in Models (one row) and a Collection (the rows), and Views own the parts of the screen. When a value changes the Model fires an event, and the View hears it and rewrites the screen.

// Style D: Backbone 1.6 (2010 on)
// The real values live in Models (one row) and a Collection (the rows). When a value changes, the Model fires an event.
// Views rewrite the screen. Which event rewrites what is still yours to write here.
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: '' }); // exchange rate

/** A row View: owns one table row (tr) */
const RowView = Backbone.View.extend({
  tagName: 'tr',
  template: _.template($('#row-template').html()), // turn the template text into a function
  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); }, // screen → Model: you write this yourself
  drop() { rows.remove(this.model); },
  render() { this.$el.html(this.template(this.model.toJSON())); return this; },
});

/** The View for the whole screen */
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); // rows added or removed, or a value changed
    this.listenTo(settings, 'change', this.renderSummary);
  },
  add() { … },       // rows.add({}), then the cursor to the new row
  addOne(row) { … }, // make a RowView and append it
  removeOne(row) { … },
  /** Redraw the total, count, button and rate */
  renderSummary() {
    const total = …; // add up row by row with rows.each
    this.$('#total').text(formatYen(total));
    this.$('#receipt-note').prop('hidden', !needsReceipt(total));
    this.$('#count').text(ROWS_TEXT.count(rows.length)); // ★ count
    this.$('#add-row').prop('disabled', rows.length >= MAX_ROWS);
    this.$('#rate-note').text(rate === null ? '' : ROWS_TEXT.rate(rate, settings.get('date')));
  },
});

The line that shows the count (★) sits in renderSummary next to the total, the same spot as in part 1's jQuery. This time it doesn't drift. renderSummary isn't called by a button; it's called by the Collection's update (rows added or removed) and change (a value changed). Whatever action caused it, a changed value fires the same event. Part 1's "actions × functions" table became a "values × functions" table.

Rewriting the screen is still your job, though. this.$('#total').text(…) has the same shape as in jQuery, and the screen-to-Model direction (edit) is hand-written too.

3

Knockout: tie values to the screen

Knockout (version E) follows MVVM. Values go into observables (containers you can watch), and the HTML's data-bind says which value each part is tied to.

# rows-ko.html (excerpt): the HTML says which value each part is tied to
<h1 …>Expense line items <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 (excerpt)
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);

  // total: recomputed whenever an observable it read (rows, amounts, currencies, rate) changes
  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)); // ★ count

textInput: amount ties both ways: type in the box and the value changes; change the value and the box changes. The total is a pureComputed: Knockout remembers which observables it read (rows, amounts, currencies, rate) and recalculates when any of them changes. The count (★) is one line. Not a single line of JavaScript rewrites the screen.

Arai37 lines of JavaScript! Half of jQuery's.

FuruiAnd 10 expressions written into the HTML. Both count as things you wrote.

4

AngularJS: watch plain values

AngularJS (version F) was built by developers at Google, and values just go into plain objects. In the HTML, ng-model ties a value and an input box both ways, and {{ }} shows a value as text.

# rows-ng.html (excerpt): ng-model ties values to boxes, {{ }} ties values to text
<main … ng-app="rows">
<div ng-controller="RowsController as vm">
<h1 …>Expense line items <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 (excerpt): values are just plain objects
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 = () => { … }; // add up vm.rows row by row (same sum as D and E)
  vm.totalText = () => formatYen(vm.total());
  vm.needsReceipt = () => needsReceipt(vm.total());
  vm.countText = () => ROWS_TEXT.count(vm.rows.length); // ★ count
  vm.rateText = () => (vm.rate ? ROWS_TEXT.rate(vm.rate.rate, vm.rate.date) : '');

With no observable containers, how does it know something changed? After every click or keystroke, AngularJS re-evaluates every expression on the screen (the watchers) and compares each with its last value. This is called dirty checking. Only the differences are written to the screen.

Three schools: from where values live to the screen

D Backbone (MVC) into the Model you write set events change, update View functions you write the DOM you pick what the tool says when E Knockout (MVVM) observable both ways computed remembers reads only tied parts data-bind tool knows what fine-grained ties F AngularJS plain values ng-model ⇄ dirty checking all watchers only changes {{ }}, ng-* found by checking watchers per row With jQuery, a person remembered everything that needed fixing All three moved part of it to the tool, each in its own way
5

The same eight actions

Playwright ran part 1's eight actions (type amounts, add rows, switch to dollars, remove a row), and I looked at the screen after each.

# The same eight actions as part 1 (D, E and F were written with the count from the start); A and C for comparison
  style         count wrong   total wrong   cursor after add        after remove
  D Backbone    0 / 8         0 / 8         new row's Description   Add a row
  E Knockout    0 / 8         0 / 8         Add a row               BODY
  F AngularJS   0 / 8         0 / 8         Add a row               BODY
  A jQuery      3 / 8         0 / 8         new row's Description   Add a row
  C React       0 / 8         0 / 8         Add a row               BODY
# D, E and F: zero in all three languages. Only D moves the cursor like A, because it was written by hand

None of the three let the count or total drift even once (the same on the Japanese and Chinese pages). By 2010, part 1's problem already had answers.

The cursor went different ways. D does what jQuery did: after adding, the new row; after removing, the Add a row button. Backbone didn't do that; it was written by hand. E and F behave like React (C): write nothing, and nothing happens.

6

What one keystroke rewrites

On a three-row table I typed 14720 one character at a time and counted the screen changes with a MutationObserver each time (the same method as part 1).

# Typing 1, 4, 7, 2, 0 into row 1's amount on a three-row table: screen changes per key (MutationObserver)
  style         records     what changed
  D Backbone    7           replaces the total, count and rate text and re-sets the notice's hidden, changed or not
  E Knockout    1           only the total text
  F AngularJS   3 → 1       only the total text (3 on the first key, as the box's class gains ng-dirty)
  A jQuery      5           replaces the total and count text
  C React       23          21 are React re-setting name and type to the same values

The difference is clear. D replaces even the count and rate text that didn't change, every time, because renderSummary doesn't know what changed and redraws it all. E touches only the total text. Knockout remembers which part of the screen is tied to which value, so it writes only where a changed value leads. F also changed only the total text, but it got there by comparing every watcher.

7

With many rows

With 10, 1000 and 5000 rows, I timed the page's JavaScript work for one keystroke, and the time from opening the page until all rows were there. For comparison, part 1's A (jQuery) and C (React) were measured again on the same day.

# JavaScript time per keystroke, and time to open (median of 3 runs, each the median of 20)
  style         JS per key (ms)           open until all rows (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   —
  (no JS)       just setting a value and drawing: 28.5 ms at 1000 rows, 131.6 ms at 5000 (the browser's work)
# F's watchers: 76 at 10 rows, 7,006 at 1000, 35,006 at 5000 — seven per row
# A renames every row on each addition, so 5000 rows was not measured
A terminal with the output of node keys2.js 5000. rows-react.html: JS 84.2 ms, drawn 223.2 ms. rows-bb.html: 1.5 ms and 133.7 ms. rows-ko.html: 3.6 ms and 105.0 ms. rows-ng.html: 16.3 ms and 149.0 ms, with 35006 watchers
One run of timing each keystroke on a 5000-row table (the figures in the text are medians of three runs). AngularJS has 35,006 watchers. (Captured by the author on a cloud Linux machine, 1 November 2026. The bar above each window was drawn in for explanation.)

One keystroke on 5000 rows: JavaScript time

One keystroke on a 5000-row table: JavaScript time (measured) D Backbone 1.6 ms E Knockout 3.9 ms F AngularJS 16.4 ms C React (part 1) 83.0 ms (browser drawing) 132 ms The 2010 three beat React, which redoes the whole table per key Closing that gap is part 8; why React still won is part 3

F has seven watchers per row (three input boxes and four spoken labels). At 5000 rows that's 35,006, all compared on every keystroke, so it took 16.4 ms. Even so, like E at 3.9 ms and D at 1.6 ms, it beat React's 83 ms. React rebuilds the shape of all 5000 rows and compares it on every keystroke (part 8 narrows that gap).

One more thing: at 5000 rows, just setting a value and drawing took about 132 ms. That's the browser's work and the same for every version. On a screen with many rows, the first lever is the number of rows, not the tool. And A, which renames every row each time one is added, took 9.7 seconds to open 1000 rows.

8

Under today's CSP

So far the security headers were off. With Frontend part 15's CSP (script-src 'self', style-src 'self') turned on, this happened.

Three browser windows opened under Frontend part 15's strict CSP, after typing 14720. D Backbone shows only the table headings, no rows, and Total: 0. E Knockout has one row with 14720 typed, but the count and total are empty and the receipt notice is showing. F AngularJS shows 1 line(s) and Total: ¥14,720, so it works, but the receipt notice stays although the total is under ¥30,000
Opened under the strict CSP. D shows no rows, E connects nothing, and F works but the notice it should hide stays. (Captured by the author on a cloud Linux machine, 1 November 2026. The bar above each window was drawn in for explanation.)
# Opened under Frontend part 15's CSP (script-src 'self', style-src 'self') and typed 14720 in row 1
  style           errors  what happened
  D Backbone      1       no rows; _.template can't build a function from text
  E Knockout      1       nothing is connected (Unable to parse bindings)
  F AngularJS     1       works, but can't add ng-hide's CSS, so the notice stays
  F + ng-csp      0       with ng-csp and angular-csp.css: no errors
  A / C           0       works

In D, _.template, which builds the row template, tried to make a function from text and was stopped (Backbone itself has no such spot that I could find). E stopped while turning the data-bind expressions into functions, and nothing was connected. Around 2010 it was normal to turn expressions written in HTML or strings into JavaScript on the spot; that style predates wide use of CSP.

F ran, but it couldn't add the CSS for ng-hide with a <style>, so the notice stayed although the total was under ¥30,000. AngularJS saw CSP coming and provided ng-csp plus a separate angular-csp.css; with those it ran with no errors. It's the same wall Vue hit without a build in Frontend part 15.

9

Size, and where each is now

# How much was hand-written, and how much reaches the browser (app.js and the text strings are the same for all and left out)
  style         JS lines  HTML ties       delivered     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 lines exclude blanks (comments included). HTML ties count data-bind, ng-* and {{ }}. D is jQuery 78,748 + Underscore 19,668 + Backbone 25,690

E and F halved the JavaScript but put expressions into the HTML. D also needs jQuery and Underscore: 128 KB for the three. F alone is 179 KB. All are smaller than React (226 KB).

  name        first release                 latest                now
  Backbone    Oct 2010                      1.6.1 (Apr 2025)      still maintained
  Knockout    Jul 2010                      3.5.3 (Mar 2026)      still maintained
  AngularJS   2010 (1.0 in Jun 2012)        1.8.3 (Apr 2022)      support ended 31 Dec 2021; deprecated on npm
# Latest versions and dates from npm's records (checked 1 November 2026)

AngularJS support ended on 31 December 2021; its successor, Angular, is a rebuilt and different thing. Backbone and Knockout still get new releases. Some stopped, some carry on. When you inherit an old business app, first check which one it's written in.

10

On your own PC

If you have an AngularJS screen at hand, you can count its watchers in the developer tools console (counting from the element with ng-app).

// on an AngularJS screen: the number of watchers
angular.element(document.querySelector('[ng-app]')).injector().get('$rootScope').$$watchersCount

Whether a page may build functions from text under its CSP can be tried anywhere with one line. On a page with a strict CSP it throws.

// can this page build a function from text? (throws if the CSP doesn't allow 'unsafe-eval')
new Function('return 1')()

Names starting with $$ are AngularJS internals; use them only for investigating.

§ — On one page, and next time

  1. In 2010 Backbone, Knockout and AngularJS arrived almost together. All of them keep the real values off the screen.
  2. Backbone announces changes with events and leaves the screen to you. Knockout ties values to the screen; AngularJS watches everything and compares.
  3. In the same eight actions, all three drifted zero times. Screen changes per keystroke: 7, 1 and 1.
  4. At 5000 rows, 1.6, 3.9 and 16.4 ms per keystroke: faster than React's 83 ms. The 132 ms just to draw is the browser's.
  5. Under a strict CSP, building expressions from text stops working. AngularJS runs with ng-csp, and its support ended in 2021.

AraiFast, no drift, and small. So why did everyone switch to React?

FuruiThis table is a ten-row exercise. Once a screen has hundreds or thousands of parts, two-way ties made it hard to trace where a change came from.

AraiTied up so much they got tangled?

FuruiNext time, history part two. From React bringing in "one way" to Vue, Angular, Svelte, Solid, and building screens on the server.

References

Recorded by the author on a cloud Linux machine on 1 November 2026. Pages were served by a practice server (Node.js 22), and driven and measured with Chromium 153.0.8010.12 under Playwright. D uses jQuery 4.0.0, Underscore 1.13.8 and Backbone 1.6.1; E Knockout 3.5.3; F AngularJS 1.8.3; all are styled with Bootstrap 5.3.8. Times come from setting a box's value and sending an input event: until it returns (JavaScript time) and until after the next frame, measured 25 times, dropping the first 5, as the median of 20, then the median of three such runs. Release years follow each project's records and the references; latest versions follow npm's records. Numbers will differ on other machines.

Old-School Front-End, Frameworks part 2 — History I, from jQuery to MVC / 2026.11

← Back to all posts