jQuery 4 (legacy code and migration to DOM APIs) Module 2 – Selecting, reading and changing the page
$.each, $.map, $.extend: utilities and native code
What $.each, $.map, $.grep, $.extend, $.inArray and $.param do, the native code that gives the same results, where it differs, and what jQuery 4 removed.
What you will learn
- Predict what $.each, $.map, $.grep and $.inArray return, including the order of each callback's arguments
- Replace each utility with native code that gives the same results, and name the cases where the native call differs
- Choose between $.extend, Object.assign, spread, structuredClone and a merge you write, for copies and for defaults
- Explain why URLSearchParams changes what a PHP server receives from $.param, and keep the bracket names it expects
- Replace the utilities jQuery 4 removed, such as $.trim and $.isArray, without changing what the code returns
Before you start
What you’ll learn · Before you start
What you will learn
- Predict what $.each, $.map, $.grep and $.inArray return, including the order of each callback's arguments
- Replace each utility with native code that gives the same results, and name the cases where the native call differs
- Choose between $.extend, Object.assign, spread, structuredClone and a merge you write, for copies and for defaults
- Explain why URLSearchParams changes what a PHP server receives from $.param, and keep the bracket names it expects
- Replace the utilities jQuery 4 removed, such as $.trim and $.isArray, without changing what the code returns
Before you start
On this page
A checkout script tests if ($.inArray('card', methods)), and the card option disappears whenever card comes first
in the list: $.inArray returns its position, 0, which is falsy. jQuery’s utility functions look like the array
methods every browser now has, but each one has its own rules: the order of a callback’s arguments, what stops a loop,
and what is dropped, shared or copied.
The order-report pages of this lesson run each utility in jsdom on jQuery 4.0.0 and 3.7.1, next to the native code
that replaces it, and a PHP program shows what a server receives. You will predict what each utility returns, write
native code with the same results, choose a copy or a merge for each $.extend call, keep the form bodies a PHP server
expects, and replace the eleven utilities that jQuery 4 removed.
$.each passes the index first and stops when the callback returns false
$.each(collection, callback) calls the callback with the index and then the value for an array or an
array-like object: An object with a length and items numbered 0 to length - 1, such as a function's arguments, a NodeList or a jQuery object, that is not an array. $.each() and $.map() go through it by index., or the key and then the value for any other object. It stops as soon as the callback returns false; any other return value moves on to the next
item, like continue (jQuery.each()). forEach() passes the value first and
cannot stop at all. The order report runs both, and for...of with break, on the same four items:
orders/each.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Order report</title>
</head>
<body>
<ul id="items">
<li>kettle</li>
<li>lamp</li>
</ul>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="each.js"></script>
</body>
</html> orders/each.js
// each.js: $.each against the native loops, on an order's items, an array with a hole and a plain object.
const items = ['kettle', 'lamp', 'clock', 'fan'];
const viaEach = [];
$.each(items, function (index, value) {
viaEach.push(`${index}:${value}`);
if (value === 'lamp') return false; // false stops $.each
});
console.log(`$.each, return false at lamp: ${viaEach.join(' ')}`);
const viaForEach = [];
items.forEach((value, index) => {
viaForEach.push(`${index}:${value}`);
if (value === 'lamp') return false; // forEach ignores what the callback returns
});
console.log(`forEach, return false at lamp: ${viaForEach.join(' ')}`);
const viaForOf = [];
for (const [index, value] of items.entries()) {
viaForOf.push(`${index}:${value}`);
if (value === 'lamp') break;
}
console.log(`for...of, break at lamp: ${viaForOf.join(' ')}`);
// this is the value, wrapped in an object outside strict mode.
$.each([5], function (index, value) {
console.log(`this in a $.each callback: typeof ${typeof this}, this == value ${this == value}, this === value ${this === value}`);
});
// An array with a hole: the middle position was never set.
const sparse = [1, , 3];
const a = [];
$.each(sparse, (index, value) => a.push(`${index}=${value}`));
const b = [];
sparse.forEach((value, index) => b.push(`${index}=${value}`));
const c = [];
for (const value of sparse) c.push(String(value));
console.log(`a hole: $.each ${a.join(' ')} | forEach ${b.join(' ')} | for...of ${c.join(' ')}`);
// A plain object: $.each passes key and value; Object.entries() gives the same pairs to a native loop.
const stock = { kettle: 12, lamp: 0 };
const d = [];
$.each(stock, (key, value) => d.push(`${key}=${value}`));
const e = [];
for (const [key, value] of Object.entries(stock)) e.push(`${key}=${value}`);
console.log(`an object: $.each ${d.join(' ')} | Object.entries ${e.join(' ')}`); // Prints what orders/each.js logged in jsdom (recorded.mjs).
import { show } from './show.mjs';
show('orders/each.html'); Output
$.each, return false at lamp: 0:kettle 1:lamp forEach, return false at lamp: 0:kettle 1:lamp 2:clock 3:fan for...of, break at lamp: 0:kettle 1:lamp this in a $.each callback: typeof object, this == value true, this === value false a hole: $.each 0=1 1=undefined 2=3 | forEach 0=1 2=3 | for...of 1 undefined 3 an object: $.each kettle=12 lamp=0 | Object.entries kettle=12 lamp=0 (jQuery 3.7.1 logged the same 6 lines)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node each.mjs
return false stopped $.each after the lamp, while forEach went on to the fan: it ignores what its callback
returns. for...of with break stopped where $.each did, and items.entries() gave it the index too. Three more
lines matter when you translate an old loop, and the table after them sums up how each loop stops and treats a hole.
Inside a function callback,
this is the value, but outside strict mode JavaScript wraps it in an object, as jQuery’s documentation warns: this == value was true and this === value false. An array with a hole, [1, , 3], gave $.each the index 1 with
undefined, as for...of did, while forEach skipped it. For a plain object, Object.entries() gives for...of the
same key and value pairs.
| Loop | To stop early | A hole in the array |
|---|---|---|
$.each(array, fn) |
return false |
visited, as undefined |
array.forEach(fn) |
not possible | skipped |
for...of over array.entries() |
break |
visited, as undefined |
Moving return false into forEach
Wrong: items.forEach(...) with return false inside, which runs over every item. Right: for...of with break,
over items.entries() when you need the index, or items.some(...) when the loop searches for the first match.
Checkpoint
Check: $.each and native loops
$.map flattens what the callback returns and drops null
$.map(collection, callback) builds a new array from what the callback returns, with two rules that
Array.prototype.map() lacks: a result that is null or undefined is removed, and a result that is an array is
flattened into the result (jQuery.map()). The callback also gets the value first
and the index second, the other way round from $.each. The order report maps four orders to their SKUs:
orders/map.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Order report</title>
</head>
<body>
<ul id="items">
<li>kettle</li>
<li>lamp</li>
</ul>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="map.js"></script>
</body>
</html> orders/map.js
// map.js: $.map and $.grep against map(), flatMap() and filter(), and the order of the arguments each callback gets.
const show = (list) => `[${list.map((v) => (v === undefined ? 'undefined' : JSON.stringify(v))).join(', ')}]`;
const orders = [
{ id: 'A1', skus: ['7310', '0042'] },
{ id: 'A2', skus: [] },
{ id: 'A3', skus: null },
{ id: 'A4', skus: ['5521'] },
];
console.log(`$.map(orders, o => o.skus): ${show($.map(orders, (o) => o.skus))}`);
console.log(`orders.map(o => o.skus): ${show(orders.map((o) => o.skus))}`);
console.log(`orders.flatMap(o => o.skus ?? []): ${show(orders.flatMap((o) => o.skus ?? []))}`);
console.log(`$.map(orders, o => o.note): ${show($.map(orders, (o) => o.note))}; orders.map(o => o.note): ${show(orders.map((o) => o.note))}`);
console.log(`one level only: $.map([[1, [2]]], x => x) ${show($.map([[1, [2]]], (x) => x))}; flatMap ${show([[1, [2]]].flatMap((x) => x ?? []))}`);
// An object: $.map passes value, then key.
const stock = { kettle: 12, lamp: 0 };
console.log(`$.map(stock, (v, k) => k + ':' + v): ${show($.map(stock, (v, k) => `${k}:${v}`))}; Object.entries: ${show(Object.entries(stock).map(([k, v]) => `${k}:${v}`))}`);
// $.grep keeps the items the callback accepts; with true as its third argument, the ones it rejects.
const counts = [12, 0, 5, 0];
console.log(`$.grep(counts, n => n > 0): ${show($.grep(counts, (n) => n > 0))}; with true: ${show($.grep(counts, (n) => n > 0, true))}`);
console.log(`counts.filter(n => n > 0): ${show(counts.filter((n) => n > 0))}; filter(n => !(n > 0)): ${show(counts.filter((n) => !(n > 0)))}`);
// What each callback receives first and second.
const first2 = (args) => [...args].slice(0, 2).map((v) => (v && v.nodeType === 1 ? `<${v.tagName.toLowerCase()}>` : JSON.stringify(v))).join(', ');
let seen;
$.each(['kettle'], function () { seen = first2(arguments); });
console.log(`$.each(array, fn) calls fn(${seen})`);
$.map(['kettle'], function () { seen = first2(arguments); return null; });
console.log(`$.map(array, fn) calls fn(${seen})`);
$.grep(['kettle'], function () { seen = first2(arguments); return true; });
console.log(`$.grep(array, fn) calls fn(${seen})`);
$('#items li').first().each(function () { seen = first2(arguments); });
console.log(`$('li').each(fn) calls fn(${seen})`);
$('#items li').first().map(function () { seen = first2(arguments); return null; });
console.log(`$('li').map(fn) calls fn(${seen})`);
['kettle'].forEach(function () { seen = first2(arguments); });
console.log(`array.forEach(fn) and array.map(fn) call fn(${seen})`); // Prints what orders/map.js logged in jsdom (recorded.mjs).
import { show } from './show.mjs';
show('orders/map.html'); Output
$.map(orders, o => o.skus): ["7310", "0042", "5521"]
orders.map(o => o.skus): [["7310","0042"], [], null, ["5521"]]
orders.flatMap(o => o.skus ?? []): ["7310", "0042", "5521"]
$.map(orders, o => o.note): []; orders.map(o => o.note): [undefined, undefined, undefined, undefined]
one level only: $.map([[1, [2]]], x => x) [1, [2]]; flatMap [1, [2]]
$.map(stock, (v, k) => k + ':' + v): ["kettle:12", "lamp:0"]; Object.entries: ["kettle:12", "lamp:0"]
$.grep(counts, n => n > 0): [12, 5]; with true: [0, 0]
counts.filter(n => n > 0): [12, 5]; filter(n => !(n > 0)): [0, 0]
$.each(array, fn) calls fn(0, "kettle")
$.map(array, fn) calls fn("kettle", 0)
$.grep(array, fn) calls fn("kettle", 0)
$('li').each(fn) calls fn(0, <li>)
$('li').map(fn) calls fn(0, <li>)
array.forEach(fn) and array.map(fn) call fn("kettle", 0)
(jQuery 3.7.1 logged the same 14 lines)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node map.mjs
$.map(orders, o => o.skus) gave one flat list of three SKUs: the two arrays were spread out, the empty one added
nothing and null was dropped. orders.map() kept four entries, arrays and null included, while
flatMap(o => o.skus ?? []) matched $.map exactly: flatMap() also flattens one level, and ?? [] turns a missing
value into nothing. A callback that returns nothing at all gave $.map an empty array and map() four undefined
entries. $.grep(counts, fn) is filter(), and its third argument, true, keeps the items the callback rejects
(jQuery.grep()).
The last lines of the run are the trap of every translation: each function orders its callback’s arguments its own way, and swapping two names gives no error.
| Call | The callback receives |
|---|---|
$.each(array, fn) |
(index, value) |
$.map(array, fn), $.grep(array, fn) |
(value, index) |
$('li').each(fn), $('li').map(fn) |
(index, element) |
array.forEach(fn), array.map(fn), array.filter(fn) |
(value, index) |
Checkpoint
Check: $.map, $.grep and their arguments
$.inArray returns a position, so a find at 0 looks like a miss
$.inArray(value, array) returns the position of the value, or −1 when it is not there, and its documentation warns
that 0, the first position, is loosely equal to false, so the test must compare with −1
(jQuery.inArray()). The checkout of this page offers a payment method when
its old test says so:
orders/inarray.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Order report</title>
</head>
<body>
<ul id="items">
<li>kettle</li>
<li>lamp</li>
</ul>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="inarray.js"></script>
</body>
</html> orders/inarray.js
// inarray.js: $.inArray returns a position, so the first position, 0, is falsy and "not found", -1, is truthy.
const methods = ['card', 'cash', 'wallet'];
const old = (m) => ($.inArray(m, methods) ? 'offered' : 'not offered'); // the checkout's old test
console.log(`$.inArray: card ${$.inArray('card', methods)}, cash ${$.inArray('cash', methods)}, cheque ${$.inArray('cheque', methods)}`);
console.log(`if ($.inArray(m, methods)): card ${old('card')}, cash ${old('cash')}, cheque ${old('cheque')}`);
const fixed = (m) => ($.inArray(m, methods) !== -1 ? 'offered' : 'not offered');
console.log(`if ($.inArray(m, methods) !== -1): card ${fixed('card')}, cash ${fixed('cash')}, cheque ${fixed('cheque')}`);
console.log(`methods.includes(m): card ${methods.includes('card')}, cash ${methods.includes('cash')}, cheque ${methods.includes('cheque')}`);
// The comparison is strict, as indexOf's is; includes() differs only for NaN.
console.log(`'5' in [5]: $.inArray ${$.inArray('5', [5])}, indexOf ${[5].indexOf('5')}, includes ${[5].includes('5')}`);
console.log(`NaN in [NaN]: $.inArray ${$.inArray(NaN, [NaN])}, indexOf ${[NaN].indexOf(NaN)}, includes ${[NaN].includes(NaN)}`); // Prints what orders/inarray.js logged in jsdom (recorded.mjs).
import { show } from './show.mjs';
show('orders/inarray.html'); Output
$.inArray: card 0, cash 1, cheque -1 if ($.inArray(m, methods)): card not offered, cash offered, cheque offered if ($.inArray(m, methods) !== -1): card offered, cash offered, cheque not offered methods.includes(m): card true, cash true, cheque false '5' in [5]: $.inArray -1, indexOf -1, includes false NaN in [NaN]: $.inArray -1, indexOf -1, includes true (jQuery 3.7.1 logged the same 6 lines)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node inarray.mjs
The old test was wrong both ways: card, at position 0, was not offered, and cheque, which is not in the list, was
offered, because −1 is truthy. Comparing with !== -1 fixed it, and methods.includes(m) says what the test means.
The comparison is strict, as it is in indexOf(), so none of the three found the text '5' in [5]. includes()
also finds NaN, which the other two never find.
Key idea
Translate a $.inArray test by what it asks: includes() for “is it there”, and indexOf() only when the code
needs the position.
Checkpoint
Check: $.inArray and includes()
$.extend skips undefined, and its deep form merges arrays by index
$.extend(target, ...sources) copies the keys of each source into the target, left to right, and returns the target.
It skips values that are undefined, and with true as its first argument it merges nested objects and arrays instead
of replacing them (jQuery.extend()). Object.assign() and spread look the
same at a glance. The order report merges the options of its orders table into the defaults four ways:
orders/extend.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Order report</title>
</head>
<body>
<ul id="items">
<li>kettle</li>
<li>lamp</li>
</ul>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="extend.js"></script>
</body>
</html> orders/extend.js
// extend.js: $.extend against Object.assign and spread, shallow and deep, on an orders table's defaults and options.
const show = (v) => JSON.stringify(v, (key, value) => (value === undefined ? '(undefined)' : value));
const defaults = () => ({ theme: 'light', pageSize: 20, columns: ['sku', 'name', 'price'], labels: { empty: 'No orders', more: 'Load more' } });
const options = { theme: undefined, pageSize: 50, columns: ['sku'], labels: { empty: 'Nothing yet' } };
console.log(`$.extend({}, defaults, options): ${show($.extend({}, defaults(), options))}`);
console.log(`Object.assign({}, defaults, options): ${show(Object.assign({}, defaults(), options))}`);
console.log(`{ ...defaults, ...options }: ${show({ ...defaults(), ...options })}`);
console.log(`$.extend(true, {}, defaults, options): ${show($.extend(true, {}, defaults(), options))}`);
// A shallow copy shares the nested objects; a deep one does not.
const base = defaults();
const copy = $.extend({}, base);
copy.labels.empty = 'Changed in the copy';
console.log(`after copy.labels.empty = 'Changed in the copy': base.labels.empty ${show(base.labels.empty)}; copy.labels === base.labels ${copy.labels === base.labels}`);
const deep = $.extend(true, {}, base);
console.log(`$.extend(true, {}, base): deep.labels === base.labels ${deep.labels === base.labels}, deep.columns === base.columns ${deep.columns === base.columns}`);
// A native merge with $.extend(true)'s rules: undefined values skipped, plain objects and arrays merged key by key,
// and the key __proto__ skipped, as jQuery has done since 3.4.0.
const isPlain = (v) => v !== null && typeof v === 'object' && [Object.prototype, null].includes(Object.getPrototypeOf(v));
function merge(target, ...sources) {
for (const source of sources) {
for (const [key, value] of Object.entries(source)) {
if (key === '__proto__' || value === undefined) continue;
if (Array.isArray(value)) target[key] = merge(Array.isArray(target[key]) ? target[key] : [], value);
else if (isPlain(value)) target[key] = merge(isPlain(target[key]) ? target[key] : {}, value);
else target[key] = value;
}
}
return target;
}
console.log(`merge({}, defaults, options): ${show(merge({}, defaults(), options))}`);
// Settings saved as JSON and read back, with a key that names the prototype.
const saved = JSON.parse('{"pageSize": 10, "__proto__": {"isAdmin": true}}');
const viaJquery = $.extend(true, {}, defaults(), saved);
const viaMerge = merge({}, defaults(), saved);
console.log(`with a "__proto__" key: $.extend(true) gives pageSize ${viaJquery.pageSize}, isAdmin ${viaJquery.isAdmin}; merge() gives pageSize ${viaMerge.pageSize}, isAdmin ${viaMerge.isAdmin}; ({}).isAdmin ${({}).isAdmin}`); // Prints what orders/extend.js logged in jsdom (recorded.mjs).
import { show } from './show.mjs';
show('orders/extend.html'); Output
$.extend({}, defaults, options): {"theme":"light","pageSize":50,"columns":["sku"],"labels":{"empty":"Nothing yet"}}
Object.assign({}, defaults, options): {"theme":"(undefined)","pageSize":50,"columns":["sku"],"labels":{"empty":"Nothing yet"}}
{ ...defaults, ...options }: {"theme":"(undefined)","pageSize":50,"columns":["sku"],"labels":{"empty":"Nothing yet"}}
$.extend(true, {}, defaults, options): {"theme":"light","pageSize":50,"columns":["sku","name","price"],"labels":{"empty":"Nothing yet","more":"Load more"}}
after copy.labels.empty = 'Changed in the copy': base.labels.empty "Changed in the copy"; copy.labels === base.labels true
$.extend(true, {}, base): deep.labels === base.labels false, deep.columns === base.columns false
merge({}, defaults, options): {"theme":"light","pageSize":50,"columns":["sku","name","price"],"labels":{"empty":"Nothing yet","more":"Load more"}}
with a "__proto__" key: $.extend(true) gives pageSize 10, isAdmin undefined; merge() gives pageSize 10, isAdmin undefined; ({}).isAdmin undefined
(jQuery 3.7.1 logged the same 8 lines)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node extend.mjs
The first four lines hold three differences. theme: undefined kept the default with $.extend and became
undefined with Object.assign() and spread. The shallow forms replaced labels whole, so its "more" text was lost,
and only the deep form merged it. And the deep form merged columns by index: the options’ ["sku"] replaced the
first of three columns and kept the other two, which is rarely what an option that names the columns means. The next
two lines show the cost of a shallow copy: A new object whose properties hold the same values as the original's, so a nested object or array is shared, not copied: changing it through the copy changes the original. $.extend({}, obj), Object.assign() and spread make one.: the copy and the original shared one labels
object, so changing the copy changed the original.
structuredClone() makes a deep copy: A copy in which nested objects and arrays are copied too, so changing the copy never changes the original. structuredClone() makes one of plain data; $.extend(true, {}, obj) makes one of plain objects and arrays. that shares nothing, Dates included, but it copies
one object and merges nothing, and it refuses functions
(HTML Standard). It needs no page,
so this program runs on Node.js:
// clone.mjs: structuredClone() makes a deep copy of plain data and refuses what is not data. It needs no page, so this
// program runs on Node.js itself.
const base = { theme: 'light', columns: ['sku', 'name'], labels: { empty: 'No orders' }, since: new Date(0) };
const copy = structuredClone(base);
copy.labels.empty = 'Changed in the copy';
console.log(`base.labels.empty ${JSON.stringify(base.labels.empty)}; copy.columns === base.columns ${copy.columns === base.columns}; copy.since is a Date ${copy.since instanceof Date}`);
try {
structuredClone({ ...base, onRow: () => {} });
} catch (error) {
console.log(`a copy of an object with a function in it: ${error.name}`);
} Output
base.labels.empty "No orders"; copy.columns === base.columns false; copy.since is a Date true a copy of an object with a function in it: DataCloneError
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node clone.mjs
When code merges options into defaults, write its rules down, as the page’s merge() does: skip undefined, merge
plain objects key by key, and decide what an array means. Answer four questions about one $.extend call of your own,
and the table marks what can replace it:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Replacement | undefined in the options | Nested objects | Arrays in the options | Functions inside |
|---|---|---|---|---|
| $.extend({}, a, b) Works on: the top-level keys | Good skipped: theme stayed "light" | Poor replaced whole: labels lost its "more" text | Mixed replaced, but the result shares the array | Good copied by reference |
| $.extend(true, {}, a, b) Works on: every level | Good skipped: theme stayed "light" | Good merged: labels kept "more" and took the new "empty" | Poor merged by index: ["sku"] over three columns kept all three | Good copied by reference |
| Object.assign() or spread Works on: the top-level keys | Poor copied: theme became undefined | Poor replaced whole: labels lost its "more" text | Mixed replaced, but the result shares the array | Good copied by reference |
| structuredClone(a) Works on: every level of one object | Mixed kept as it is: it copies one object and merges nothing | Good copied at every level: nothing is shared | Good copied, never merged | Poor throws DataCloneError on a function |
| A merge you write Works on: the rules you choose | Good skipped, when the merge skips it as the lesson's merge() does | Good merged key by key | Good merged or replaced, as you decide | Good copied by reference |
How the answers decide
- “What does the call do?” Copies one object so that nothing is shared: rules out $.extend({}, a, b) and Object.assign() or spread. A shallow copy shares the nested objects: changing copy.labels.empty changed the original in the run.
- “What does the call do?” Merges options into defaults: rules out structuredClone(a). structuredClone() copies one object and merges nothing.
- “What does the call do?” Merges options into defaults: adds a caveat to $.extend({}, a, b). A shallow $.extend() merges only the top level: labels was replaced whole and lost its "more" text in the run.
- “Can an option be undefined, meaning "use the default"?” Yes: rules out Object.assign() or spread. Object.assign() and spread copy undefined values: theme became undefined in the run, where $.extend() kept "light".
- “Must an array in the options replace the default array?” Yes, the options say which columns to show: rules out $.extend(true, {}, a, b). $.extend(true) merges arrays by index, so ["sku"] over three columns kept all three in the run.
- “Do the values include functions?” Yes, callbacks such as onRow: rules out structuredClone(a). structuredClone() refuses functions: it threw DataCloneError in the Node.js run.
A merge of saved data skips __proto__
Settings read back from JSON can hold the key __proto__. Before 3.4.0, a deep $.extend copied it and added
properties to Object.prototype, which every object inherits; this prototype pollution: A flaw in which code that merges or copies data it did not write follows a key such as __proto__ into Object.prototype, so every object of the program inherits the added property. jQuery 3.4.0 and later skip that key in $.extend(). is CVE: Common Vulnerabilities and Exposures: the public list that gives each known security flaw an identifier such as CVE-2020-11023, so that advisories, scanners and fixes all refer to the same problem. More in “jQuery in 2026: versions, support and where it runs”-2019-11358 (CVE record).
jQuery 3.4.0 and later skip the key, and so must any merge you write: in the last line of the run, both left
isAdmin undefined.
Checkpoint
Check: Copies and merges
$.param writes brackets that PHP reads; URLSearchParams writes text
$.param(object) turns an object into a form body, and for arrays and nested objects it writes the bracket names that
PHP and Ruby on Rails read: items[] for each item of a list, address[city] for each key of an object
(jQuery.param()). new URLSearchParams(object) takes each value as text
instead, because its constructor reads an object as a record of strings
(URL Standard). Step through one order sent both ways:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The order. The page holds one order: items, a list of two SKUs; address, an object with a city and a PIN code; and a note.
- $.param writes brackets. $.param(order) names each item of the list items[], and each key of the address address[city] and address[pin]: the form that PHP and Ruby on Rails read.
- PHP rebuilds the order. PHP turns the two items[] pairs into a list of two, and address[city] and address[pin] into an array: the server sees the order as the page had it.
- URLSearchParams writes text. new URLSearchParams(order) turns each value into text: the list becomes "SKU-7310,SKU-0042", and the address becomes "[object Object]".
- PHP gets two strings. PHP receives items as one string and address as the text [object Object]. The order has lost its structure, and nothing reported an error on either side.
- Brackets without jQuery. bracketParams(order) appends the same bracket names to a URLSearchParams, so PHP gets its list and its array back with no jQuery on the page.
The order page printed each body, and also the traditional form, $.param(order, true), which repeats items=
without brackets and turns the address into text as well:
orders/param.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Order report</title>
</head>
<body>
<ul id="items">
<li>kettle</li>
<li>lamp</li>
</ul>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="param.js"></script>
</body>
</html> orders/param.js
// param.js: one order encoded as a form body with $.param, with new URLSearchParams() and with a native encoder that
// writes the same bracket names as $.param.
const order = { items: ['SKU-7310', 'SKU-0042'], address: { city: 'Pune', pin: '411001' }, note: 'leave at gate' };
const viaParam = $.param(order);
console.log(`$.param(order): ${viaParam}`);
console.log(` decoded: ${decodeURIComponent(viaParam)}`);
const viaSearchParams = new URLSearchParams(order).toString();
console.log(`new URLSearchParams(order): ${viaSearchParams}`);
console.log(` decoded: ${decodeURIComponent(viaSearchParams.replaceAll('+', ' '))}`);
console.log(`$.param(order, true), the traditional form: ${$.param(order, true)}`);
/**
* Appends a value as $.param names it: items[] for the items of an array (items[0][...] for objects in it) and
* address[city] for the keys of an object.
*/
function bracketParams(value, prefix = '', params = new URLSearchParams()) {
if (Array.isArray(value)) {
value.forEach((v, i) => bracketParams(v, `${prefix}[${v !== null && typeof v === 'object' ? i : ''}]`, params));
} else if (value !== null && typeof value === 'object') {
for (const [key, v] of Object.entries(value)) bracketParams(v, prefix ? `${prefix}[${key}]` : key, params);
} else {
params.append(prefix, value ?? '');
}
return params;
}
const viaBrackets = bracketParams(order).toString();
console.log(`bracketParams(order): ${viaBrackets}`);
console.log(` the same pairs as $.param: ${decodeURIComponent(viaBrackets.replaceAll('+', ' ')) === decodeURIComponent(viaParam)}`); // Prints what orders/param.js logged in jsdom (recorded.mjs).
import { show } from './show.mjs';
show('orders/param.html'); Output
$.param(order): items%5B%5D=SKU-7310&items%5B%5D=SKU-0042&address%5Bcity%5D=Pune&address%5Bpin%5D=411001¬e=leave%20at%20gate decoded: items[]=SKU-7310&items[]=SKU-0042&address[city]=Pune&address[pin]=411001¬e=leave at gate new URLSearchParams(order): items=SKU-7310%2CSKU-0042&address=%5Bobject+Object%5D¬e=leave+at+gate decoded: items=SKU-7310,SKU-0042&address=[object Object]¬e=leave at gate $.param(order, true), the traditional form: items=SKU-7310&items=SKU-0042&address=%5Bobject%20Object%5D¬e=leave%20at%20gate bracketParams(order): items%5B%5D=SKU-7310&items%5B%5D=SKU-0042&address%5Bcity%5D=Pune&address%5Bpin%5D=411001¬e=leave+at+gate the same pairs as $.param: true (jQuery 3.7.1 logged the same 7 lines)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node param.mjs
PHP reads bracket names in a request as arrays (PHP manual),
and its parse_str() parses a body “as if it were the query string passed via a URL”
(parse_str). This program decodes four of the bodies the page
printed:
<?php
// decode.php: what PHP makes of four form bodies that the order page printed (orders/param.js). parse_str() parses a
// string "as if it were the query string passed via a URL", so name[] becomes a list and name[key] an array.
$bodies = [
'$.param(order)' => 'items%5B%5D=SKU-7310&items%5B%5D=SKU-0042&address%5Bcity%5D=Pune'
. '&address%5Bpin%5D=411001¬e=leave%20at%20gate',
'new URLSearchParams(order)' => 'items=SKU-7310%2CSKU-0042&address=%5Bobject+Object%5D¬e=leave+at+gate',
'$.param(order, true)' => 'items=SKU-7310&items=SKU-0042&address=%5Bobject%20Object%5D¬e=leave%20at%20gate',
'bracketParams(order)' => 'items%5B%5D=SKU-7310&items%5B%5D=SKU-0042&address%5Bcity%5D=Pune'
. '&address%5Bpin%5D=411001¬e=leave+at+gate',
];
foreach ($bodies as $from => $body) {
parse_str($body, $fields);
$items = is_array($fields['items'])
? 'a list of ' . count($fields['items'])
: 'the text "' . $fields['items'] . '"';
echo $from, ': ', json_encode($fields, JSON_UNESCAPED_SLASHES), "\n";
echo ' items is ', $items, "\n";
} Output
$.param(order): {"items":["SKU-7310","SKU-0042"],"address":{"city":"Pune","pin":"411001"},"note":"leave at gate"}
items is a list of 2
new URLSearchParams(order): {"items":"SKU-7310,SKU-0042","address":"[object Object]","note":"leave at gate"}
items is the text "SKU-7310,SKU-0042"
$.param(order, true): {"items":"SKU-0042","address":"[object Object]","note":"leave at gate"}
items is the text "SKU-0042"
bracketParams(order): {"items":["SKU-7310","SKU-0042"],"address":{"city":"Pune","pin":"411001"},"note":"leave at gate"}
items is a list of 2
Recorded with PHP 8.5.11 (cli) (NTS) on macOS 26 arm64. To run it yourself: php decode.php
From $.param, PHP got a list of two items and an address array. From URLSearchParams, it got items as the single
text "SKU-7310,SKU-0042" and address as "[object Object]": the order lost its structure, and neither side
reported an error. From the traditional form it kept only the last of the two items= values. bracketParams() appends the same names as $.param to a URLSearchParams, so PHP got its arrays
back. It writes a space as + where $.param writes %20, and both mean a space in a form body.
Swapping $.param for URLSearchParams in one step
Wrong: new URLSearchParams(order) for a body with arrays or nested objects, which sends "[object Object]". Right:
build the bracket names, as bracketParams() does, or send JSON to a server that reads JSON; for a flat object of
strings the two give the same pairs.
Checkpoint
Check: $.param and URLSearchParams
jQuery 4 removed eleven utilities that native code replaces
jQuery 4.0 removed eleven utilities that jQuery 3 had deprecated, release by release, and the upgrade guide gives a native replacement for most of them (4.0 upgrade guide). Walk through when each one went:
- 3.0
parseJSON() and unique() deprecated deprecated
jQuery.parseJSON() had become the same as the browser's JSON.parse(), and jQuery.unique() was renamed jQuery.uniqueSort(), a name that says it sorts. The undocumented jQuery.camelCase() was deprecated in the same release.
- 3.2
isArray() deprecated deprecated
Array.isArray() does the same in every browser jQuery 3 supports. The never-documented jQuery.nodeName() was deprecated here too.
- 3.3
isFunction(), isNumeric(), isWindow(), now() and type() deprecated deprecated
typeof value === "function" and Date.now() replace two of them directly; isNumeric(), isWindow() and type() need a test chosen for each use, because no one native call gives the same answers.
- 3.5
trim() deprecated deprecated
String.prototype.trim() strips the same spaces, but it is a method of strings: jQuery.trim(null) returned "", while null.trim() throws a TypeError.
- 4.0
All eleven removed removed
On jQuery 4.0.0 each of them is undefined, so a call throws "is not a function". jQuery Migrate 4.0.2 brings every one back with a warning that names it, which is how an upgrade finds the calls.
The timeline has 5 events, from 3.0 to 4.0.
The order report calls the ones that have a direct replacement, on jQuery 4.0.0, on 3.7.1, and on 4.0.0 with jQuery Migrate 4.0.2:
orders/removed.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Order report</title>
</head>
<body>
<ul id="items">
<li>kettle</li>
<li>lamp</li>
</ul>
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="removed.js"></script>
</body>
</html> orders/removed.js
// removed.js: which of the utilities removed in jQuery 4 this jQuery has, and the native code that replaces each one.
const names = ['trim', 'isArray', 'parseJSON', 'isFunction', 'isNumeric', 'type', 'isWindow', 'now', 'camelCase', 'nodeName', 'unique'];
console.log(`jQuery ${$.fn.jquery} has: ${names.filter((n) => typeof $[n] === 'function').join(', ') || 'none of them'}`);
const kept = ['each', 'map', 'grep', 'extend', 'inArray', 'param', 'isPlainObject', 'isEmptyObject', 'contains', 'uniqueSort', 'escapeSelector', 'parseHTML', 'noop'];
console.log(`and keeps: ${kept.filter((n) => typeof $[n] === 'function').join(', ')}`);
/** Runs one call and says what it returned, or what it threw. */
const run = (label, fn) => {
try {
return `${label} ${JSON.stringify(fn())}`;
} catch (error) {
return `${label} throws ${error.name}`;
}
};
const city = ' Pune ';
const nothing = null;
function onRow() {}
console.log([run('$.trim(city)', () => $.trim(city)), run('city.trim()', () => city.trim())].join(' | '));
console.log([run('$.trim(nothing)', () => $.trim(nothing)), run('nothing.trim()', () => nothing.trim()), run("String(nothing ?? '').trim()", () => String(nothing ?? '').trim())].join(' | '));
console.log([run('$.isArray([1])', () => $.isArray([1])), run('Array.isArray([1])', () => Array.isArray([1]))].join(' | '));
console.log([run(`$.parseJSON('{"a":1}')`, () => $.parseJSON('{"a":1}')), run(`JSON.parse('{"a":1}')`, () => JSON.parse('{"a":1}'))].join(' | '));
console.log([run('$.isFunction(onRow)', () => $.isFunction(onRow)), run("typeof onRow === 'function'", () => typeof onRow === 'function')].join(' | '));
console.log([run('typeof $.now()', () => typeof $.now()), run('typeof Date.now()', () => typeof Date.now())].join(' | '));
// isNumeric has no one-to-one replacement: the upgrade guide's Number.isFinite() treats strings differently.
console.log([run("$.isNumeric('12')", () => $.isNumeric('12')), run("Number.isFinite('12')", () => Number.isFinite('12')), run("Number.isFinite(Number('12'))", () => Number.isFinite(Number('12')))].join(' | '));
console.log([run("$.isNumeric('')", () => $.isNumeric('')), run("Number.isFinite(Number(''))", () => Number.isFinite(Number('')))].join(' | ')); // Prints what orders/removed.js logged in jsdom: on jQuery 4.0.0 and 3.7.1, then on jQuery 4.0.0 with jQuery Migrate
// 4.0.2 (recorded.mjs).
import { show } from './show.mjs';
show('orders/removed.html');
console.log('jQuery 4.0.0 with jQuery Migrate 4.0.2:');
show('orders/removed-migrate.html'); Output
jQuery 4.0.0 has: none of them
and keeps: each, map, grep, extend, inArray, param, isPlainObject, isEmptyObject, contains, uniqueSort, escapeSelector, parseHTML, noop
$.trim(city) throws TypeError | city.trim() "Pune"
$.trim(nothing) throws TypeError | nothing.trim() throws TypeError | String(nothing ?? '').trim() ""
$.isArray([1]) throws TypeError | Array.isArray([1]) true
$.parseJSON('{"a":1}') throws TypeError | JSON.parse('{"a":1}') {"a":1}
$.isFunction(onRow) throws TypeError | typeof onRow === 'function' true
typeof $.now() throws TypeError | typeof Date.now() "number"
$.isNumeric('12') throws TypeError | Number.isFinite('12') false | Number.isFinite(Number('12')) true
$.isNumeric('') throws TypeError | Number.isFinite(Number('')) true
(jQuery 3.7.1 logged other lines:)
line 1: jQuery 3.7.1 has: trim, isArray, parseJSON, isFunction, isNumeric, type, isWindow, now, camelCase, nodeName, unique
line 3: $.trim(city) "Pune" | city.trim() "Pune"
line 4: $.trim(nothing) "" | nothing.trim() throws TypeError | String(nothing ?? '').trim() ""
line 5: $.isArray([1]) true | Array.isArray([1]) true
line 6: $.parseJSON('{"a":1}') {"a":1} | JSON.parse('{"a":1}') {"a":1}
line 7: $.isFunction(onRow) true | typeof onRow === 'function' true
line 8: typeof $.now() "number" | typeof Date.now() "number"
line 9: $.isNumeric('12') true | Number.isFinite('12') false | Number.isFinite(Number('12')) true
line 10: $.isNumeric('') false | Number.isFinite(Number('')) true
jQuery 4.0.0 with jQuery Migrate 4.0.2:
JQMIGRATE: Migrate is installed with logging active, version 4.0.2
jQuery 4.0.0 has: trim, isArray, parseJSON, isFunction, isNumeric, type, isWindow, now, camelCase, nodeName, unique
and keeps: each, map, grep, extend, inArray, param, isPlainObject, isEmptyObject, contains, uniqueSort, escapeSelector, parseHTML, noop
(warning) JQMIGRATE: jQuery.trim() is removed; use String.prototype.trim
$.trim(city) "Pune" | city.trim() "Pune"
$.trim(nothing) "" | nothing.trim() throws TypeError | String(nothing ?? '').trim() ""
(warning) JQMIGRATE: jQuery.isArray() is removed; use Array.isArray()
$.isArray([1]) true | Array.isArray([1]) true
(warning) JQMIGRATE: jQuery.parseJSON is removed; use JSON.parse
$.parseJSON('{"a":1}') {"a":1} | JSON.parse('{"a":1}') {"a":1}
(warning) JQMIGRATE: jQuery.isFunction() is removed
$.isFunction(onRow) true | typeof onRow === 'function' true
(warning) JQMIGRATE: jQuery.now() is removed; use Date.now()
typeof $.now() "number" | typeof Date.now() "number"
(warning) JQMIGRATE: jQuery.isNumeric() is removed
$.isNumeric('12') true | Number.isFinite('12') false | Number.isFinite(Number('12')) true
$.isNumeric('') false | Number.isFinite(Number('')) true
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node removed.mjs
On 4.0.0 every call threw a TypeError, because the functions are gone. On 3.7.1 each returned what its replacement
returned, with two exceptions that deserve a test. $.trim(null) gave "", while null.trim() throws, so code that
may receive null needs String(value ?? '').trim(). And $.isNumeric('12') was true while the guide’s
Number.isFinite('12') is false, because Number.isFinite() never converts a string; $.isNumeric('') was false
while Number.isFinite(Number('')) is true. $.isNumeric, $.type, $.isWindow and $.camelCase have no native
call that does the same: Migrate’s warnings advise a test that fits each use for the first two, removing the calls of
the third (where the test is still needed, obj != null && obj === obj.window, the expression jQuery used), and a
small utility of your own for the last
(warnings.md). With Migrate 4.0.2 loaded, every
function came back, and each call logged a warning that names the function.
| jQuery 3 | Native |
|---|---|
$.trim(s) |
s.trim(), or String(s ?? '').trim() when s may be null |
$.isArray(x), $.parseJSON(s) |
Array.isArray(x), JSON.parse(s) |
$.isFunction(x), $.now() |
typeof x === 'function', Date.now() |
$.nodeName(el, 'div') |
el.nodeName.toLowerCase() === 'div' |
$.unique(list) |
$.uniqueSort(list), which jQuery 4 keeps |
$.isNumeric, $.type, $.isWindow, $.camelCase |
a test or a utility chosen for each use |
The second line of the run lists what jQuery 4 keeps: $.each, $.map, $.grep, $.extend, $.inArray and
$.param themselves, and $.isPlainObject, $.isEmptyObject, $.contains, $.uniqueSort, $.escapeSelector,
$.parseHTML and $.noop. None of them needs a change for the upgrade; replacing them is a later choice, made with
the rules of this lesson.
Checkpoint
Check: Utilities removed in jQuery 4
Practise: replace $.each, $.map and $.extend
Three helpers, one for each utility, give old code the same results without jQuery. Each helper is one step, with starting code, a first hint and sample tests taken from the order report’s own cases; take the steps in any order:
Code lab · 3 steps
Replace jQuery's utilities with native helpers
Write each(), map() and extend() that follow the rules of $.each, $.map and $.extend, the last one safe with saved JSON.
- Write each() with $.each's rules To do
- Write map() with $.map's rules To do
- Write extend() with $.extend's rules, safely To do
Exercise · Medium · JavaScript
Write each() with $.each's rules
Old code calls $.each() in many places, and the team wants a small each() that behaves the same, so the calls can move off jQuery one at a time. Write it in each.mjs:
- For an array, or an array-like object such as
{ length: 3, 0: 'x', 2: 'z' }, callcallback(index, value)for every index from 0 tolength - 1, in order, holes included, withthisset to the value. - For any other object, call
callback(key, value)for each of its own keys, withthisset to the value. - Stop as soon as the callback returns
false; any other return value goes on to the next item. - Return the collection itself.
The tests run without a browser and without jQuery.
Starter code · each.mjs
/**
* Calls callback(index, value) for each item of an array or an array-like object, or callback(key, value) for each own
* key of an object, with this set to the value; stops when the callback returns false; returns the collection.
* @param {any} collection @param {(this: any, indexOrKey: number | string, value: any) => unknown} callback
*/
export function each(collection, callback) {
collection.forEach((value, index) => callback(value, index));
return collection;
} The sample tests · each.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { each } from './each.mjs';
test('passes the index first, then the value, with this set to the value', () => {
const calls = [];
each(['kettle', 'lamp'], function (index, value) {
calls.push([index, value, this]);
});
assert.deepEqual(calls, [[0, 'kettle', 'kettle'], [1, 'lamp', 'lamp']]);
});
test('stops when the callback returns false, and returns the collection', () => {
const items = ['kettle', 'lamp', 'clock', 'fan'];
const seen = [];
const result = each(items, (index, value) => {
seen.push(value);
if (value === 'lamp') return false;
return 0;
});
assert.deepEqual(seen, ['kettle', 'lamp']);
assert.equal(result, items);
});
test('visits every index of an array-like object, holes included', () => {
const seen = [];
each({ length: 3, 0: 'x', 2: 'z' }, (index, value) => seen.push(`${index}=${value}`));
each([1, , 3], (index, value) => seen.push(`${index}=${value}`));
assert.deepEqual(seen, ['0=x', '1=undefined', '2=z', '0=1', '1=undefined', '2=3']);
});
test('passes the key and the value of a plain object, and stops there too', () => {
const seen = [];
each({ kettle: 12, lamp: 0 }, (key, value) => seen.push([key, value]));
assert.deepEqual(seen, [['kettle', 12], ['lamp', 0]]);
const keys = [];
each({ kettle: 12, lamp: 0, clock: 3 }, (key) => {
keys.push(key);
if (key === 'lamp') return false;
});
assert.deepEqual(keys, ['kettle', 'lamp']);
}); A hint
A plain for loop from 0 to length - 1 visits holes, which forEach() skips, and lets you stop with break. callback.call(value, index, value) passes the index first and sets this. Use Object.entries() for an object, and compare the result with === false, so that returning 0 or undefined does not stop the loop.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · JavaScript
Write map() with $.map's rules
Next, a map() that gives the same results as $.map(). Write it in map.mjs:
- For an array or an array-like object, call
callback(value, index)for every index from 0 tolength - 1; for any other object, callcallback(value, key)for each of its own keys. Note the order: the value comes first here, unlike$.each(). - Return a new array of the results, where a result that is
nullorundefinedis left out, and a result that is an array adds its items, one level deep only.
The tests run without a browser and without jQuery.
Starter code · map.mjs
/**
* Calls callback(value, index) for each item of an array or an array-like object, or callback(value, key) for each own
* key of an object, and returns the results: null and undefined left out, arrays added one level deep.
* @param {any} collection @param {(value: any, indexOrKey: number | string) => unknown} callback
*/
export function map(collection, callback) {
return collection.map(callback);
} The sample tests · map.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { map } from './map.mjs';
test('passes the value first, then the index', () => {
assert.deepEqual(map(['kettle', 'lamp'], (value, index) => `${index}:${value}`), ['0:kettle', '1:lamp']);
});
test('leaves out null and undefined', () => {
assert.deepEqual(map([1, 2, 3, 4], (n) => (n % 2 ? n : null)), [1, 3]);
assert.deepEqual(map(['kettle'], () => undefined), []);
});
test('adds the items of an array result, one level deep', () => {
const orders = [{ skus: ['7310', '0042'] }, { skus: [] }, { skus: null }, { skus: ['5521'] }];
assert.deepEqual(map(orders, (o) => o.skus), ['7310', '0042', '5521']);
assert.deepEqual(map([1], () => [[1]]), [[1]]);
});
test('maps a plain object by key and an array-like object by index', () => {
assert.deepEqual(map({ kettle: 12, lamp: 0 }, (value, key) => `${key}:${value}`), ['kettle:12', 'lamp:0']);
assert.deepEqual(map({ length: 2, 0: 'x', 1: 'y' }, (value) => value.toUpperCase()), ['X', 'Y']);
}); A hint
Collect the results in an array as you go: skip a result when result == null, push(...result) when Array.isArray(result), and push(result) otherwise. A result such as [[1]] then adds [1], one level deep, as $.map() does.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · JavaScript
Write extend() with $.extend's rules, safely
Last, the settings code calls $.extend() both ways, shallow and deep, on defaults and on settings saved as JSON. Write extend(deep, target, ...sources) in extend.mjs:
- Copy the own keys of each source into
target, from left to right, so a later source wins, and returntarget. - Skip a value that is
undefined, so an option left undefined keeps the default. - When
deepistrue, merge a plain object or an array key by key into a copy of its own, and never share it with a source: the defaults must not change when the result does. Arrays merge by index, as$.extend(true, …)does. - Always skip the key
__proto__, as jQuery has done since 3.4.0, so saved JSON can never change the prototype of the result or of every object.
The tests run without a browser and without jQuery.
Starter code · extend.mjs
/**
* Copies the own keys of each source into target and returns target, as $.extend does: undefined values skipped,
* plain objects and arrays merged into copies when deep is true, and the key __proto__ always skipped.
* @param {boolean} deep @param {Record<string, any>} target @param {...Record<string, any>} sources
*/
export function extend(deep, target, ...sources) {
return Object.assign(target, ...sources);
} The sample tests · extend.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { extend } from './extend.mjs';
const defaults = () => ({ theme: 'light', pageSize: 20, columns: ['sku', 'name', 'price'], labels: { empty: 'No orders', more: 'Load more' } });
test('skips undefined, lets a later source win and returns the target', () => {
const target = { theme: 'light', pageSize: 20 };
const result = extend(false, target, { theme: undefined, pageSize: 50 }, { pageSize: 30 });
assert.equal(result, target);
assert.deepEqual(target, { theme: 'light', pageSize: 30 });
});
test('shares nested objects when the copy is shallow', () => {
const base = defaults();
const copy = extend(false, {}, base);
assert.equal(copy.labels, base.labels);
assert.equal(copy.columns, base.columns);
});
test('merges objects and arrays into copies when deep is true', () => {
const base = defaults();
const result = extend(true, {}, base, { columns: ['id'], labels: { empty: 'Nothing yet' } });
assert.deepEqual(result, { theme: 'light', pageSize: 20, columns: ['id', 'name', 'price'], labels: { empty: 'Nothing yet', more: 'Load more' } });
assert.notEqual(result.labels, base.labels);
assert.deepEqual(base, defaults());
const source = { rows: [{ id: 1 }] };
assert.notEqual(extend(true, {}, source).rows[0], source.rows[0], 'an object inside an array is copied too');
});
test('never lets a "__proto__" key from JSON change a prototype', () => {
const saved = JSON.parse('{"pageSize": 10, "__proto__": {"isAdmin": true}}');
const deep = extend(true, {}, saved);
const shallow = extend(false, {}, saved);
assert.equal(deep.pageSize, 10);
assert.equal(shallow.pageSize, 10);
assert.equal(deep.isAdmin, undefined);
assert.equal(shallow.isAdmin, undefined);
assert.equal({}.isAdmin, undefined);
}); A hint
Loop over Object.entries(source), continue for __proto__ and for undefined, and recurse only when deep is true and the value is an array or a plain object (Object.getPrototypeOf(value) is Object.prototype or null). The recursive call needs its own target: the existing value when it has the same kind, else a new [] or {}.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Last, the lesson’s own questions, asked one by one with your weakest idea first; every choice, right or wrong, says why:
Practice that adapts to you
Practise this lesson
15 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): how do you stop a $.each loop early, and what replaces it in native code?
Return false from the callback: $.each stops at once, and any other return value moves on to the next item, like
continue. forEach() cannot stop, because it ignores what its callback returns, so the native loop is for...of
with break, over array.entries() when the index is needed, or some() and find() when the loop searches for a
first match. Mind the arguments in the translation: $.each passes the index first, while entries() gives
[index, value] pairs and forEach() passes the value first.
Two harder questions from this module’s bank build on it: an order form whose PHP server lost its arrays, and a deep
$.extend of saved settings that has to go. Try each one yourself before you read its first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
An order form was sent with $.post(url, order). After jQuery was replaced with fetch() and new URLSearchParams(order), the PHP back end receives items as "SKU-7310,SKU-0042" and address as "[object Object]". Explain what changed, and fix it without changing the server.
-
Mid to senior
Settings code builds its options with $.extend(true, {}, defaults, JSON.parse(localStorage.getItem('prefs'))). You are asked to remove jQuery from this module. What do you replace the call with, and which of its behaviours must the replacement keep, or change on purpose?
Key takeaways
$.eachpasses the index and then the value and stops when its callback returnsfalse; the native loop isfor...ofwithbreak, becauseforEach()cannot stop and skips holes.$.mapdropsnullandundefinedand flattens one level, soflatMap(x => x ?? [])replaces it, and$.mapand$.greppass the value first where$.eachpasses the index first.$.inArrayreturns a position, so test it with!== -1, or useincludes(), which also findsNaN.$.extendskipsundefinedwhereObject.assign()and spread copy it, its deep form merges arrays by index, and every merge of saved data must skip__proto__.$.paramwrites the bracket names that PHP reads as arrays, whileURLSearchParamsturns arrays and objects into text, so a body with nested data needs its brackets built by hand.- jQuery 4 removed eleven utilities; most have one native replacement, but
$.trim(null)and$.isNumeric('12')need a test of their own.
Terms in this lesson
- array-like object
- An object with a length and items numbered 0 to length - 1, such as a function's arguments, a NodeList or a jQuery object, that is not an array. $.each() and $.map() go through it by index.
- deep copy
- A copy in which nested objects and arrays are copied too, so changing the copy never changes the original. structuredClone() makes one of plain data; $.extend(true, {}, obj) makes one of plain objects and arrays.
- prototype pollution
- A flaw in which code that merges or copies data it did not write follows a key such as __proto__ into Object.prototype, so every object of the program inherits the added property. jQuery 3.4.0 and later skip that key in $.extend().
- shallow copy
- A new object whose properties hold the same values as the original's, so a nested object or array is shared, not copied: changing it through the copy changes the original. $.extend({}, obj), Object.assign() and spread make one.
References
- Utilities (OpenJS Foundation (jQuery API documentation))
- jQuery.each() (OpenJS Foundation (jQuery API documentation))
- jQuery.map() (OpenJS Foundation (jQuery API documentation))
- jQuery.grep() (OpenJS Foundation (jQuery API documentation))
- jQuery.inArray() (OpenJS Foundation (jQuery API documentation))
- jQuery.extend() (OpenJS Foundation (jQuery API documentation))
- jQuery.param() (OpenJS Foundation (jQuery API documentation))
- jQuery Core 4.0 Upgrade Guide: deprecated APIs removed (OpenJS Foundation (jQuery))
- Deprecated 3.0, 3.2, 3.3 and 3.5: the deprecated API categories (OpenJS Foundation (jQuery API documentation))
- jQuery Migrate: warnings and their meaning (OpenJS Foundation (jQuery))
- URL Standard: URLSearchParams (WHATWG)
- HTML Standard: structuredClone() (WHATWG)
- PHP Manual: parse_str (The PHP Group)
- PHP Manual: $_POST (The PHP Group)
- PHP Manual: Variables From External Sources (The PHP Group)
- CVE-2019-11358: jQuery.extend and Object.prototype pollution (CVE Program (MITRE))
- jsdom (jsdom project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress