cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

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.

By the end you can
  • 01
    Wrap code safelyUse an IIFE, strict mode, private helpers, and one deliberate global export.
  • 02
    Design a tiny chainable APIBuild a no-new $ factory, shared prototype methods, getters, setters, and delegation.
  • 03
    Extend 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.

Working definition

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.

Real-life analogyA library wrapper is a labeled tool tray

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 this for 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 SOURCE

Classic 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.

mini-dollar.jsPop out in the code editor (opens in a new tab)JavaScript
(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.

Safety choices in the mini library
ConcernChoiceWhy it matters
TextUse textContent in text(value)Avoids parsing user input as HTML
GlobalsExport only $ and offer noConflict()Prevents accidental name collisions
PrototypesAdd methods to Collection.prototype, not Array.prototypeNo built-in behavior changes for the rest of the page
Empty matchesSetters loop zero times and getters return undefined or ""Code can run before content exists without crashing
No prototype patching

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 THROUGH

Learners 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 the $ factory
Step 0 of 4Ready
Your turn: follow the blue line

Step through how the $ factory builds small array-like wrappers while keeping methods on the prototype.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
const b = $(document.querySelectorAll(".item"));console.log(a.length);console.log(a.addClass === b.addClass);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Factory accepts several inputsPop out in the code editor (opens in a new tab)JavaScript
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.

Prototype methods are sharedPop out in the code editor (opens in a new tab)JavaScript
const a = $(".item");
const b = $(".save");
console.log(a.addClass === b.addClass);
The jQuery init trick, briefly

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.

The classic init shapeJavaScript
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

INTERACTIVE

Chainable 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.

Step through a chain
Step 0 of 4Ready
Your turn: follow the blue line

Follow $(".item").addClass("active").text("Hi"): create the collection, call a setter, call another setter, and keep returning this.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
const result = items  .addClass("active")  .text("Hi");console.log(result === items);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Run a real chainPop out in the code editor (opens in a new tab)JavaScript
$(".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.

Getter and setter overloadsPop out in the code editor (opens in a new tab)JavaScript
console.log($(".item").css("color"));
$(".item").css("color", "crimson");
$(".item").css({ backgroundColor: "lemonchiffon" });
console.log($(".item").attr("data-name"));
$(".item").attr("data-name", "renamed");
Run a tiny $ library on a real page
Playground sourcePop out in the code editor (opens in a new tab)JavaScript
const items = $(".item");items.addClass("active");items.text("Safe text");items.find("span").addClass("label");$.fn.flash = function () {  return this.addClass("highlighted");};items.flash();
Real DOM outputscoped host
Try it yourself

Reset builds a real list. Try one method at a time, then chain them.

This playground uses real DOM nodes inside a React ref. The helper is scoped to the playground so it does not touch the rest of the page.

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.

Delegated events with closestPop out in the code editor (opens in a new tab)JavaScript
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

EXTEND

A 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.

A tiny pluginPop out in the code editor (opens in a new tab)JavaScript
$.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 $.

Release and restore $Pop out in the code editor (opens in a new tab)JavaScript
const localDollar = $.noConflict();
console.log(window.$ === undefined ? "released" : "still global");
window.$ = localDollar;
console.log(localDollar(".item").length);
Plugin design checklist
  • Use ordinary functions when the plugin needs this.
  • Return this from 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.

  1. Entry: find the exported function, constructor, or module boundary.
  2. Public API: list methods users are meant to call, such as $.fn.text.
  3. One trace: follow $(".item").addClass("active").text("Hi").
  4. Tests: search for the method name and read expected behavior before internals.
  5. History: use blame or commits when a line looks surprising.
Guided tour of our own source

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

COMPARE

The 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.

Mini $ versus today's choices
QuestionMini $ libraryModern DOM APIsES modules
Main ideaA wrapper object around elements with chainable methodsDirect methods on elements, NodeLists, events, and fetchNamed exports imported where they are needed
Global shapeOne exported $; helpers stay private in an IIFENo extra global; use document, Element, and Web APIsNo global; imports are explicit per file
Extension storyPlugins add methods through $.fn and static helpers through $.extendExtend by writing ordinary functions; do not patch built-insExport more functions or compose smaller modules
Best useTeaching, legacy pages, small progressive widgetsMost modern DOM workProduction code split across files and build tools
Modern DOM versionPop out in the code editor (opens in a new tab)JavaScript
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);
});
Module-style helpersJavaScript
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;
}
Library feature or modern platform?
  • `$.fn.highlight = function () { ... }`
  • `$(".item").addClass("active").text("Hi")`
  • `event.target.closest(".item")`
  • `element.classList.add("active")`
  • `export function addClass(elements, name) { ... }`
  • `document.querySelectorAll(".item")`
Try it yourself
0 of 6 correct

Sort each card by whether it belongs to the mini library, the modern DOM platform, or an ES module style.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.
Similar ideas that are easy to mix up
IdeaLooks likeActually means
$.fn$ itselfThe prototype for wrapper instance methods
$.extendA pluginA property-copy helper for static or custom extension
Getter/setter overloadAlways chainableA getter returns a value; a setter returns the wrapper
DelegationMany listenersOne parent listener that finds matching descendants

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upProve methods are shared

Run the snippet and predict whether the two wrappers have the same method function.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const a = $(".item");
const b = $(".save");
console.log(a.addClass === b.addClass);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeAdd hide and show methods

    Write the methods so they can be chained. What should both methods return?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    $.fn.hide = function () {
      return this.css("display", "none");
    };
    $.fn.show = function () {
      return this.css("display", "");
    };
    $(".item").hide().show();

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWrite a tiny plugin

      Which extension point should receive a method that wrappers call?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      // Replace this object with the real extension point.
      const extensionPoint = {};
      extensionPoint.highlight = function () {
        return this.addClass("highlighted");
      };

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeFix a method that breaks chaining

        The method adds a class but the next call crashes. What line fixes it?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        $.fn.mark = function () {
          this.addClass("marked");
        };
        
        // Uncomment after you fix the method:
        // $(".item").mark().text("Broken");

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeMake text safe

          Choose the DOM property that displays user input as text instead of running it as HTML.

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          $.fn.text = function (value) {
            return this.each(function (element) {
              // Which property should receive value?
            });
          };

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            8 QUESTIONS
            Mini library quiz · 8 questionsScore: first tries count
            1. Question 1 of 8Why did jQuery become popular in the first place?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does the IIFE wrapper protect?

              Choose an answer to see the explanation.

            3. Question 3 of 8What does this print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const a = $(".item");
              const b = $(".save");
              console.log(a.addClass === b.addClass);

              Choose an answer to see the explanation.

            4. Question 4 of 8Why can setter methods chain?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const items = $(".item");
              console.log(items.addClass("active") === items);

              Choose an answer to see the explanation.

            5. Question 5 of 8Why is text(value) safer than an html(value) shortcut for user input?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              $(".item").text("<strong>Hi</strong>");
              console.log(document.querySelector(".item").children.length);

              Choose an answer to see the explanation.

            6. Question 6 of 8What does the delegated event example print after clicking Beta?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              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);

              Choose an answer to see the explanation.

            7. Question 7 of 8Where should a plugin add a method that every wrapper can call?

              Choose an answer to see the explanation.

            8. 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 new and normalizes selectors, elements, NodeLists, arrays, and wrappers.
            • Prototype methods are shared. Setters return this; getters return values.
            • $.fn, $.extend, and noConflict are 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.

            CompleteFrontend Clear concepts. Working examples.