Build a jQuery-style library
Build a safe chainable $ library with an IIFE, prototypes, plugins, delegated events, and modern DOM alternatives you can compare on a real page.
- 01Wrap code safelyUse an IIFE, strict mode, private helpers, and one deliberate global export.
- 02Design a tiny chainable APIBuild a no-new
$factory, shared prototype methods, getters, setters, and delegation. - 03Extend and evaluate librariesAdd plugins, avoid unsafe shortcuts, and read real source code without getting lost.
What this library is
jQuery existed because the old web was rough: selectors were not consistent, event handling differed by browser, Ajax required awkward branches, and common DOM jobs took a lot of code. It was practical engineering, not magic nostalgia.
Modern JavaScript changed the trade-off. querySelectorAll, classList, addEventListener, fetch, closest, and ES modules mean most new apps do not need a global DOM wrapper. Building one anyway is a great way to connect closures, closure patterns, the prototype chain, function prototypes, DOM navigation, and delegation.
A jQuery-style mini library is a small public factory, usually named $, that wraps selected elements in a collection object whose shared methods can change the DOM, read values, register events, and return the same wrapper for chains.
Imagine carrying a tray of tools around a workshop. The tray is not the wall, the workbench, or the tools themselves. It is a convenient handle for doing the same operation to several objects without copying the tool for each object.
- In real life: A tray holds several matching tools
- In JavaScript: A wrapper holds several elements
- In real life: One shared screwdriver sits on the wall
- In JavaScript: One prototype method is shared by all wrappers
- In real life: Use a tool, then keep holding the tray
- In JavaScript: Setter methods return
thisfor chaining - In real life: A shop can add a new tool slot
- In JavaScript: Plugins add methods through
$.fn
Where the analogy stops: A tray is passive. A JavaScript wrapper can run event handlers, mutate real DOM nodes, and expose unsafe APIs if you design it carelessly.
Our version is intentionally small and honest. It teaches structure, prototypes, chaining, plugins, and source reading. It is not a recommendation to replace modern DOM APIs in new production code.
Safe IIFE wrapper
FULL SOURCEClassic browser libraries had to run before bundlers and modules were common. An IIFE gave them a private scope immediately. Passing window and document as parameters made dependencies explicit and easy to minify, while "use strict" made accidental globals throw instead of silently leaking names.
(function (window, document) {
"use strict";
const previousDollar = window.$;
const events = new WeakMap();
const slice = Array.prototype.slice;
function isElement(value) {
return value && (value.nodeType === 1 || value === window || value === document);
}
function isListLike(value) {
return value && typeof value !== "string" && typeof value.length === "number";
}
function normalize(input, context) {
if (!input) return [];
if (input instanceof Collection) return input.toArray();
if (typeof input === "string") return slice.call((context || document).querySelectorAll(input));
if (isElement(input)) return [input];
if (isListLike(input)) return slice.call(input).filter(Boolean);
return [];
}
function classes(names) {
return String(names).trim().split(/\s+/).filter(Boolean);
}
function remember(element, record) {
const list = events.get(element) || [];
list.push(record);
events.set(element, list);
}
function Collection(input, context) {
const elements = normalize(input, context);
this.length = elements.length;
for (let index = 0; index < elements.length; index += 1) {
this[index] = elements[index];
}
}
function $(input, context) {
return new Collection(input, context);
}
Collection.prototype.toArray = function () {
return slice.call(this, 0, this.length);
};
Collection.prototype.each = function (callback) {
for (let index = 0; index < this.length; index += 1) {
callback.call(this[index], this[index], index);
}
return this;
};
Collection.prototype.map = function (callback) {
return $(this.toArray().map(function (element, index) {
return callback.call(element, element, index);
}).filter(Boolean));
};
Collection.prototype.filter = function (test) {
const predicate = typeof test === "string"
? function (element) { return element.matches(test); }
: test;
return $(this.toArray().filter(function (element, index) {
return predicate.call(element, element, index);
}));
};
Collection.prototype.find = function (selector) {
const found = [];
this.each(function (element) {
found.push.apply(found, element.querySelectorAll(selector));
});
return $(found);
};
Collection.prototype.addClass = function (names) {
return this.each(function (element) {
element.classList.add.apply(element.classList, classes(names));
});
};
Collection.prototype.removeClass = function (names) {
return this.each(function (element) {
element.classList.remove.apply(element.classList, classes(names));
});
};
Collection.prototype.toggleClass = function (name, force) {
return this.each(function (element) {
element.classList.toggle(name, force);
});
};
Collection.prototype.css = function (name, value) {
if (typeof name === "string" && value === undefined) {
return this[0] ? window.getComputedStyle(this[0])[name] : undefined;
}
const styles = typeof name === "object" ? name : { [name]: value };
return this.each(function (element) {
Object.keys(styles).forEach(function (property) {
element.style[property] = styles[property];
});
});
};
Collection.prototype.text = function (value) {
if (value === undefined) return this[0] ? this[0].textContent : "";
return this.each(function (element) {
element.textContent = value;
});
};
Collection.prototype.attr = function (name, value) {
if (value === undefined) return this[0] ? this[0].getAttribute(name) : undefined;
return this.each(function (element) {
if (value === null) element.removeAttribute(name);
else element.setAttribute(name, String(value));
});
};
Collection.prototype.on = function (type, selector, handler) {
const delegated = typeof selector === "string";
const listener = delegated ? handler : selector;
if (typeof listener !== "function") return this;
return this.each(function (element) {
const wrapped = function (event) {
if (!delegated) {
listener.call(element, event, element);
return;
}
const match = event.target.closest(selector);
if (match && element.contains(match)) listener.call(match, event, match);
};
remember(element, { type: type, selector: delegated ? selector : null, listener: listener, wrapped: wrapped });
element.addEventListener(type, wrapped);
});
};
Collection.prototype.off = function (type, selector, handler) {
const delegated = typeof selector === "string";
const listener = delegated ? handler : selector;
return this.each(function (element) {
const list = events.get(element) || [];
const kept = [];
list.forEach(function (record) {
const sameType = !type || record.type === type;
const sameSelector = !delegated || record.selector === selector;
const sameListener = !listener || record.listener === listener;
if (sameType && sameSelector && sameListener) element.removeEventListener(record.type, record.wrapped);
else kept.push(record);
});
events.set(element, kept);
});
};
$.fn = Collection.prototype;
$.extend = function (target) {
const sources = slice.call(arguments, 1);
if (sources.length === 0) {
sources.push(target);
target = $;
}
sources.forEach(function (source) {
Object.keys(source || {}).forEach(function (key) {
target[key] = source[key];
});
});
return target;
};
$.noConflict = function () {
if (window.$ === $) window.$ = previousDollar;
return $;
};
window.$ = $;
})(window, document);Read the wrapper from the outside in. The only deliberate global write is window.$ = $. Everything else, including events, normalize, classes, and Collection, lives in the closure. That is the same privacy idea from closure patterns, applied to a browser library.
| Concern | Choice | Why it matters |
|---|---|---|
| Text | Use textContent in text(value) | Avoids parsing user input as HTML |
| Globals | Export only $ and offer noConflict() | Prevents accidental name collisions |
| Prototypes | Add methods to Collection.prototype, not Array.prototype | No built-in behavior changes for the rest of the page |
| Empty matches | Setters loop zero times and getters return undefined or "" | Code can run before content exists without crashing |
The library adds methods to its own Collection.prototype. It never changes Array.prototype, NodeList.prototype, or Element.prototype. That keeps built-ins predictable for the rest of the page. See native prototypes for the bigger rule.
Factory and prototype
STEP THROUGHLearners using the library should write $(selector), not new Collection(selector). The public factory hides construction, normalizes inputs, and returns a small array-like wrapper with length and numeric indexes. It accepts a selector string, a single element, a NodeList, an array, or another wrapper.
Step through how the $ factory builds small array-like wrappers while keeping methods on the prototype.
script
const b = $(document.querySelectorAll(".item"));console.log(a.length);console.log(a.addClass === b.addClass);const items = $(".item");
const button = $(document.querySelector(".save"));
const fromList = $(document.querySelectorAll(".item"));
console.log(items.length);
console.log(button.length);
console.log(fromList[0].textContent.trim());Methods belong on the prototype. If a page creates 100 wrappers, it should not allocate 100 copies of addClass. The prototype chain lets every wrapper share the same function, which you can prove directly.
const a = $(".item");
const b = $(".save");
console.log(a.addClass === b.addClass);Real jQuery historically used an init constructor behind the public factory: jQuery.fn.init.prototype = jQuery.fn. That kept $(...) free of new while still sharing methods through one prototype. Our lesson uses the simpler return new Collection(...) so the idea is visible.
function Dollar(selector) {
return new Dollar.fn.init(selector);
}
Dollar.fn = Dollar.prototype = {
constructor: Dollar,
addClass(name) {
this[0].classList.add(name);
return this;
},
};
Dollar.fn.init = function (selector) {
this[0] = document.querySelector(selector);
this.length = this[0] ? 1 : 0;
};
Dollar.fn.init.prototype = Dollar.fn;Chainable methods
INTERACTIVEChainable setter methods do work, then return this. That is why a sequence like $(".item").addClass("active").text("Hi") reads left to right. Getters are different: text(), attr(name), or css(name) return a value from the first element, so the chain ends there.
Follow $(".item").addClass("active").text("Hi"): create the collection, call a setter, call another setter, and keep returning this.
script
const result = items .addClass("active") .text("Hi");console.log(result === items);$(".item")
.addClass("active")
.attr("data-state", "ready")
.text("Hi");
console.log($(".item").attr("class"));
console.log($(".item").attr("data-state"));
console.log($(".item").text());Getter/setter overloading is compact but it is also a design trade-off. One method name can mean “read” or “write” depending on arguments. That made small APIs pleasant, but it also hides return types. A modern API design might split getText and setText when clarity matters more than brevity.
console.log($(".item").css("color"));
$(".item").css("color", "crimson");
$(".item").css({ backgroundColor: "lemonchiffon" });
console.log($(".item").attr("data-name"));
$(".item").attr("data-name", "renamed");const items = $(".item");items.addClass("active");items.text("Safe text");items.find("span").addClass("label");$.fn.flash = function () { return this.addClass("highlighted");};items.flash();Reset builds a real list. Try one method at a time, then chain them.
DOM helpers and delegated events
The DOM methods in this library are intentionally boring. addClass, removeClass, and toggleClass call classList; css writes style properties; attr reads and writes attributes; find searches inside each wrapped element. Those pieces build on modifying the DOM and styles and classes.
The text(value) setter uses textContent, not innerHTML. That means a string like <img src=x onerror=alert(1)> becomes text, not markup. The upcoming XSS lesson goes deeper on why parsing untrusted HTML is dangerous.
const list = $(".todo-list");
let heard = "";
function rememberClick(event) {
heard = this.dataset.name;
}
list.on("click", ".item", rememberClick);
document.querySelector('[data-name="Beta"]').click();
list.off("click", ".item", rememberClick);
console.log(heard);Delegation attaches one listener to a stable parent and uses event.target.closest(selector) to find the matching child. That is the same idea as the dedicated delegation lesson, packaged as a library method. off stores the wrapped listener in a private WeakMap so it can remove the exact function later.
Plugins and extension points
EXTENDA small library becomes useful when teams can extend it without editing the core file. Classic jQuery exposed $.fn as an alias for the wrapper prototype. Add a function there and every wrapper can call it. The plugin must return this when it behaves like a setter, or it will break chains.
$.fn.highlight = function (color = "gold") {
return this.css("backgroundColor", color).addClass("highlighted");
};
$(".item").highlight("gold").text("Plugin");
console.log($(".item").attr("class"));
console.log($(".item").text());Static extension is separate. $.extend(target, source) copies properties onto an object, and $.extend(source) copies onto $ itself. noConflict() is the escape hatch for pages where another script already owned $.
$const localDollar = $.noConflict();
console.log(window.$ === undefined ? "released" : "still global");
window.$ = localDollar;
console.log(localDollar(".item").length);- Use ordinary functions when the plugin needs
this. - Return
thisfrom setter-style plugins. - Keep plugin state private with closures or
WeakMap, not random globals. - Document whether the plugin is a getter, a setter, or both.
Read library source without drowning
Production libraries are larger than this lesson, but the reading strategy is the same. Start at the entry point, find the public API, trace one real call, read the tests for intended behavior, then use git blame or history only when you need to know why a line changed. Do not start by reading every helper alphabetically.
- Entry: find the exported function, constructor, or module boundary.
- Public API: list methods users are meant to call, such as
$.fn.text. - One trace: follow
$(".item").addClass("active").text("Hi"). - Tests: search for the method name and read expected behavior before internals.
- History: use blame or commits when a line looks surprising.
Ask Tuto to show the source tour. It walks from the IIFE to the factory, prototype methods, event storage, plugin hook, and noConflict. That is the same path you can use in unfamiliar open-source libraries.
Modern DOM and modules
COMPAREThe honest conclusion is not “old libraries were silly.” They solved real problems. The conclusion is that browser APIs improved. Today, many teams write direct DOM code for small widgets, use frameworks for larger UI, and share utilities with ES modules instead of exporting a global.
| Question | Mini $ library | Modern DOM APIs | ES modules |
|---|---|---|---|
| Main idea | A wrapper object around elements with chainable methods | Direct methods on elements, NodeLists, events, and fetch | Named exports imported where they are needed |
| Global shape | One exported $; helpers stay private in an IIFE | No extra global; use document, Element, and Web APIs | No global; imports are explicit per file |
| Extension story | Plugins add methods through $.fn and static helpers through $.extend | Extend by writing ordinary functions; do not patch built-ins | Export more functions or compose smaller modules |
| Best use | Teaching, legacy pages, small progressive widgets | Most modern DOM work | Production code split across files and build tools |
document.querySelectorAll(".item").forEach((element) => {
element.classList.add("active");
element.textContent = "Hi";
});
document.querySelector(".todo-list")?.addEventListener("click", (event) => {
const item = event.target.closest(".item");
if (item) console.log(item.dataset.name);
});export function selectAll(selector, root = document) {
return [...root.querySelectorAll(selector)];
}
export function addClass(elements, name) {
for (const element of elements) element.classList.add(name);
return elements;
}`$.fn.highlight = function () { ... }``$(".item").addClass("active").text("Hi")``event.target.closest(".item")``element.classList.add("active")``export function addClass(elements, name) { ... }``document.querySelectorAll(".item")`
Sort each card by whether it belongs to the mini library, the modern DOM platform, or an ES module style.
Common misconceptions
- “An IIFE makes code secure.” It protects names, not unsafe DOM operations or network calls.
- “Array-like means Array.” The wrapper has indexes and
length, but it is a custom object with its own prototype. - “Every method should chain.” Getters should return values. Setters usually return
this. - “Plugins are always safe.” A plugin can still leak globals, parse HTML, or break chains if it ignores the contract.
- “jQuery is obsolete, so its source teaches nothing.” Its patterns still teach API design, compatibility, tests, and migration thinking.
- “Small helpers should patch native prototypes.” Use your own wrapper or module functions instead.
| Idea | Looks like | Actually means |
|---|---|---|
$.fn | $ itself | The prototype for wrapper instance methods |
$.extend | A plugin | A property-copy helper for static or custom extension |
| Getter/setter overload | Always chainable | A getter returns a value; a setter returns the wrapper |
| Delegation | Many listeners | One parent listener that finds matching descendants |
Practice exercises
5 EXERCISESRun the snippet and predict whether the two wrappers have the same method function.
const a = $(".item");
const b = $(".save");
console.log(a.addClass === b.addClass);It prints true because both wrappers resolve addClass through Collection.prototype.
Write the methods so they can be chained. What should both methods return?
$.fn.hide = function () {
return this.css("display", "none");
};
$.fn.show = function () {
return this.css("display", "");
};
$(".item").hide().show();$.fn.hide = function () {
return this.css("display", "none");
};
$.fn.show = function () {
return this.css("display", "");
};
$(".item").hide();
console.log($(".item").css("display"));
$(".item").show();
console.log($(".item").css("display") === "none");hide and show both return this, so $(".item").hide().show() can keep chaining.
Which extension point should receive a method that wrappers call?
// Replace this object with the real extension point.
const extensionPoint = {};
extensionPoint.highlight = function () {
return this.addClass("highlighted");
};$.fn.highlight = function () {
return this.addClass("highlighted");
};
console.log($(".item").highlight().attr("class"));$.fn points at Collection.prototype, so adding highlight there makes it available to every wrapper.
The method adds a class but the next call crashes. What line fixes it?
$.fn.mark = function () {
this.addClass("marked");
};
// Uncomment after you fix the method:
// $(".item").mark().text("Broken");$.fn.mark = function () {
this.addClass("marked");
};
try {
$(".item").mark().text("Broken");
} catch (error) {
console.log(error.name);
}
$.fn.mark = function () {
this.addClass("marked");
return this;
};
console.log($(".item").mark().text("Fixed").text());The first version returns undefined, so .text fails. Adding return this; keeps the chain alive.
Choose the DOM property that displays user input as text instead of running it as HTML.
$.fn.text = function (value) {
return this.each(function (element) {
// Which property should receive value?
});
};$(".item").text("<img src=x onerror=alert(1)>");
console.log(document.querySelector(".item").children.length);
console.log($(".item").text());textContent treats the string as text. The snippet logs 0 children, proving no <img> element was parsed.
Check your understanding
8 QUESTIONSQuestion 1 of 8Why did jQuery become popular in the first place?
Choose an answer to see the explanation.
Question 2 of 8What does the IIFE wrapper protect?
Choose an answer to see the explanation.
Question 3 of 8What does this print?
Read the code, then predictconst a = $(".item"); const b = $(".save"); console.log(a.addClass === b.addClass);Choose an answer to see the explanation.
Question 4 of 8Why can setter methods chain?
Read the code, then predictconst items = $(".item"); console.log(items.addClass("active") === items);Choose an answer to see the explanation.
Question 5 of 8Why is
text(value)safer than anhtml(value)shortcut for user input?Read the code, then predict$(".item").text("<strong>Hi</strong>"); console.log(document.querySelector(".item").children.length);Choose an answer to see the explanation.
Question 6 of 8What does the delegated event example print after clicking Beta?
Read the code, then predictconst list = $(".todo-list"); let heard = ""; function rememberClick(event) { heard = this.dataset.name; } list.on("click", ".item", rememberClick); document.querySelector('[data-name="Beta"]').click(); list.off("click", ".item", rememberClick); console.log(heard);Choose an answer to see the explanation.
Question 7 of 8Where should a plugin add a method that every wrapper can call?
Choose an answer to see the explanation.
Question 8 of 8What would you usually choose in a modern app instead of a global
$?Choose an answer to see the explanation.
Key takeaways
- An IIFE can keep helpers private while exporting one deliberate global.
- The factory hides
newand normalizes selectors, elements, NodeLists, arrays, and wrappers. - Prototype methods are shared. Setters return
this; getters return values. $.fn,$.extend, andnoConflictare extension and coexistence points, not decoration.- Modern DOM APIs and ES modules cover most new use cases, but reading old library source still teaches durable design habits.
Final definition: a mini DOM library is a safe wrapper around selection, shared prototype behavior, chainable setters, and explicit extension points.
Next: build a tiny reactive UI from the same DOM and API-design ideas.