JavaScript Design Patterns: An Overview
JavaScript is a dynamic, object-oriented programming language that is commonly used for building interactive web applications. As JavaScript codebases grow in size and complexity, it becomes increasingly important to structure code in a modular and maintainable way. This is where design patterns come in handy.
Design patterns are reusable solutions to commonly occurring problems in software design. They provide templates for how to solve issues related to objects creation, modularization, maintainability, and more. Here are some of the most useful design patterns for JavaScript developers:
Constructor Pattern
The constructor pattern is used to create multiple object instances that share similar properties and behaviors. This pattern provides a blueprint for object creation by defining a constructor function that initializes object properties.
For example:
function Car(model, year, color) {
this.model = model;
this.year = year;
this.color = color;
}
const myCar1 = new Car('Toyota', 2020, 'red');
const myCar2 = new Car('Tesla', 2022, 'blue');
The Car constructor defines what properties a car object should have. myCar1 and myCar2 are instantiated from the constructor and will inherit the same property structure.
Module Pattern
The module pattern provides privacy and encapsulation by grouping related code into a single object literal. Only public members are returned, while private members remain hidden inside the closure.
Example:
const counterModule = (function () {
let count = 0;
return {
increment() {
count++;
},
reset() {
count = 0;
},
getCount() {
return count;
}
};
})();
counterModule.increment();
counterModule.getCount(); // 1
Here counterModule is an instance of a module that encapsulates the count variable and methods that act on it. This keeps count hidden and private while exposing an API to interact with the module.
Observer Pattern
The observer pattern allows objects to subscribe to event notifications from other objects called subjects. This pattern provides a one-to-many dependency between objects to propagate events/changes.
Example:
class Subject {
constructor() {
this.observers = [];
}
subscribe(observer) {
this.observers.push(observer);
}
unsubscribe(observer) {
// Remove observer
}
notify(data) {
this.observers.forEach(observer => observer.update(data));
}
}
class Observer {
constructor(state) {
this.state = state;
}
update(data) {
this.state = data;
}
}
const subject = new Subject();
const observer1 = new Observer(1);
subject.subscribe(observer1);
subject.notify(2);
observer1.state; // 2
The Subject maintains a list of observers and iterates through them when notify() is called, updating each one's state. Observers subscribe to the subject to receive updates.
Prototype Pattern
The prototype pattern is used to create objects that share a common prototype. New instances inherit properties and methods from the prototype object via prototypal inheritance.
Example:
const carPrototype = {
start() {
return 'Vroom!';
},
stop() {
return 'Screech!';
}
};
const myCar = Object.create(carPrototype);
myCar.start(); // 'Vroom!'
myCar inherits from carPrototype. New instances can be created without redefining all the shared methods on each one. Useful for conserving memory usage.
Factory Pattern
The factory pattern provides an interface (factory) for creating related objects without specifying the exact class/constructor. This allows encapsulating object creation logic in one place.
Example:
class Car {
constructor(options) {
// ...
}
}
class Truck {
constructor(options) {
// ...
}
}
class VehicleFactory {
createVehicle(type, options) {
switch (type) {
case 'car':
return new Car(options);
case 'truck':
return new Truck(options);
}
}
}
const factory = new VehicleFactory();
const myCar = factory.createVehicle('car', {
color: 'red'
});
The factory hides the specifics of the constructor calls and centralizes the creation process. New vehicle classes can easily be added without changing existing code.
Decorator Pattern
The decorator pattern dynamically adds behaviors and responsibilities to objects without modifying their class/constructor. This provides greater flexibility by layering functionality via decorators.
Example:
class Shape {
draw() {
// Default shape draw logic
}
}
function DecoratedShape(shape) {
this.shape = shape;
}
DecoratedShape.prototype.resize = function () {
// Resize shape logic
};
const circle = new Shape();
const decoratedCircle = new DecoratedShape(circle);
decoratedCircle.resize();
decoratedCircle.shape.draw();
The decoratedCircle adds resize ability while retaining access to the underlying Shape's draw method. Multiple decorators can be stacked to combine behaviors.
Strategy Pattern
The strategy pattern defines a family of interchangeable algorithms/behaviors and encapsulates them in separate classes. This allows selecting between different strategies to accomplish the same task.
Example:
class PaymentStrategy {
pay(amount) { }
}
class CreditCardStrategy extends PaymentStrategy {
pay(amount) {
// Credit card payment logic
}
}
class PayPalStrategy extends PaymentStrategy {
pay(amount) {
// PayPal payment logic
}
}
class Order {
constructor(paymentStrategy) {
this.paymentStrategy = paymentStrategy;
}
processPayment(amount) {
this.paymentStrategy.pay(amount);
}
}
const order = new Order(new CreditCardStrategy());
order.processPayment(500);
The PaymentStrategy is implemented by CreditCardStrategy and PayPalStrategy. The Order class can take any payment strategy and the processPayment method delegates to the strategy object, allowing easy swapping of payment processors.
Singleton Pattern
The singleton pattern ensures only one instance of a class exists throughout the runtime of an application. It provides global access to an object without needing to create new instances.
Example:
class Logger {
constructor() {
if (Logger.instance) {
return Logger.instance;
}
Logger.instance = this;
}
log(message) {
console.log(message);
}
}
const logger1 = new Logger();
const logger2 = new Logger();
logger1 === logger2; // true
Object.freeze(Logger);
The constructor checks if an instance already exists before creating a new one. This ensures only one Logger instance will ever get created.
Iterator Pattern
The iterator pattern provides sequential access to elements of a collection without exposing the underlying structure. This allows consuming datasets of different types using a consistent interface.
Example:
class Iterator {
constructor(items) {
this.index = 0;
this.items = items;
}
next() {
return this.items[this.index++];
}
hasNext() {
return this.index < this.items.length;
}
}
const iterator = new Iterator(['Hi', 'there', 'reader!']);
while (iterator.hasNext()) {
console.log(iterator.next());
}
The Iterator handles accessing and traversing the data, freeing up client code from worrying about the implementation details.
Command Pattern
The command pattern encapsulates actions/operations as objects that implement a common execute() method. This standardizes invocation, adds queuing/undo support, and decouples objects calling the commands from actual implementation.
Example:
class Command {
constructor(receiver) {
this.receiver = receiver;
}
execute() {
// Subclasses override
}
}
class PasteCommand extends Command {
execute() {
this.receiver.paste();
}
}
class Editor {
paste() {
// Paste logic
}
}
const editor = new Editor();
const paste = new PasteCommand(editor);
paste.execute();
The Command defines the common interface while ConcreteCommand subclasses handle calling the appropriate receiver methods.
In summary,
JavaScript design patterns like singleton, iterator and command provide proven solutions for managing object instantiation, traversal, execution and more. Learning to apply patterns appropriately will level up your code organization, flexibility and reusability.
State Pattern
The state pattern represents state using separate objects that encapsulate state-specific behaviors. This allows switching an object's behavior by changing its current state.
Example:
class State {
constructor(state) {
this.state = state;
}
toggle() { }
}
class OnState extends State {
constructor() {
super('on');
}
toggle() {
console.log('Turning off...');
return new OffState();
}
}
class OffState extends State {
constructor() {
super('off');
}
toggle() {
console.log('Turning on...');
return new OnState();
}
}
class Switch {
constructor() {
this.state = new OffState();
}
toggle() {
this.state = this.state.toggle();
}
}
const s = new Switch();
s.toggle(); // 'Turning on...'
s.toggle(); // 'Turning off...'
The separate OnState and OffState objects encapsulate the toggle logic for their respective states. The Switch initializes with a state and delegates toggle behavior to the current State object.
Proxy Pattern
The proxy pattern provides a placeholder/surrogate for another object and controls access to it. This allows handling operations such as lazy loading, caching, access control, etc.
Example:
class Image {
constructor(url) {
this.url = url;
}
render() {
console.log('rendering image');
return this;
}
}
class ProxyImage {
constructor(url) {
this.url = url;
this.image = null;
}
render() {
if (!this.image) {
this.image = new Image(this.url);
}
return this.image.render();
}
}
const proxyImage = new ProxyImage('example.jpg');
proxyImage.render(); // 'rendering image'
The ProxyImage defers creating the real Image object until the image is actually needed for rendering. It manages access to the real Image behind the scenes.
In summary, state, proxy and other patterns encapsulate behaviors and access in ways that promote flexibility and reusability in JavaScript code. Applying patterns properly helps manage complexity as applications scale up.
Conclusion
Design patterns enable JavaScript developers to write code that is organized, robust and maintainable. Here are some key takeaways:
- Patterns provide proven solutions to common programming challenges.
- They facilitate modular and loose coupling of components.
- Applying patterns properly helps manage complexity as applications grow.
- Patterns promote code reuse, flexibility and maintainability.
- Mastering common patterns like factory, observer, decorator etc. is key for JavaScript developers.
- New ES6 features like classes and modules support implementation of certain patterns.
- Patterns can be combined and adapted to address specific needs.
- Overusing patterns when unnecessary can over-complicate code.
- Striking a balance is important for keeping code clean and readable.
While
JavaScript design patterns take time to learn, they are an invaluable asset for programmers looking to improve their code quality and productivity. They enable writing large, scalable JavaScript applications that are engineered for maintainability and performance.