Explore the Structural Design Patterns in JavaScript, including Adapter, Bridge, Composite, Decorator, Facade, Flyweight, and Proxy. Learn how these patterns help in object composition and managing relationships between entities.
Structural design patterns are a crucial aspect of software engineering, focusing on the composition of classes and objects to form larger structures. These patterns simplify the design by identifying a simple way to realize relationships between entities. In JavaScript, structural patterns help manage the dynamic nature of the language, allowing developers to create flexible and reusable code.
Structural patterns are concerned with how classes and objects are composed to form larger structures. They facilitate the design by ensuring that if one part of a system changes, the entire system doesn’t need to be rewritten. These patterns help in defining clear relationships between different components, making the system more understandable and maintainable.
In this section, we will explore several key structural patterns, including Adapter, Bridge, Composite, Decorator, Facade, Flyweight, and Proxy. Each of these patterns serves a unique purpose and can be applied to solve specific design challenges in JavaScript applications.
The Adapter Pattern allows incompatible interfaces to work together. It acts as a bridge between two incompatible interfaces, enabling them to communicate. This pattern is particularly useful when integrating new components into an existing system.
Example:
// Existing Class that needs adaptation
class OldInterface {
request() {
console.log('Old Interface Request');
}
}
// New Interface that client expects
class NewInterface {
specificRequest() {
console.log('New Interface Specific Request');
}
}
// Adapter to make OldInterface compatible with NewInterface
class Adapter extends OldInterface {
constructor() {
super();
this.newInterface = new NewInterface();
}
request() {
this.newInterface.specificRequest();
}
}
const adapter = new Adapter();
adapter.request(); // Output: New Interface Specific Request
Diagram:
classDiagram class Client { +request() } class Adapter { +request() } class NewInterface { +specificRequest() } Client --> Adapter Adapter --> NewInterface
The Bridge Pattern decouples an abstraction from its implementation, allowing them to vary independently. This pattern is useful when both the abstractions and their implementations should be extensible by subclassing.
Example:
// Abstraction
class RemoteControl {
constructor(device) {
this.device = device;
}
togglePower() {
if (this.device.isEnabled()) {
this.device.disable();
} else {
this.device.enable();
}
}
}
// Implementation
class TV {
constructor() {
this.on = false;
}
isEnabled() {
return this.on;
}
enable() {
console.log('TV is now ON');
this.on = true;
}
disable() {
console.log('TV is now OFF');
this.on = false;
}
}
// Usage
const tv = new TV();
const remote = new RemoteControl(tv);
remote.togglePower(); // Output: TV is now ON
remote.togglePower(); // Output: TV is now OFF
Diagram:
classDiagram class RemoteControl { +togglePower() } class Device { +isEnabled() +enable() +disable() } class TV { +isEnabled() +enable() +disable() } RemoteControl --> Device TV --> Device
The Composite Pattern allows you to compose objects into tree structures to represent part-whole hierarchies. This pattern lets clients treat individual objects and compositions of objects uniformly.
Example:
// Component
class Graphic {
draw() {
throw new Error('This method should be overridden!');
}
}
// Leaf
class Circle extends Graphic {
draw() {
console.log('Drawing a Circle');
}
}
// Composite
class CompositeGraphic extends Graphic {
constructor() {
super();
this.children = [];
}
add(graphic) {
this.children.push(graphic);
}
draw() {
this.children.forEach(child => child.draw());
}
}
// Usage
const circle1 = new Circle();
const circle2 = new Circle();
const composite = new CompositeGraphic();
composite.add(circle1);
composite.add(circle2);
composite.draw(); // Output: Drawing a Circle, Drawing a Circle
Diagram:
classDiagram class Graphic { +draw() } class Circle { +draw() } class CompositeGraphic { +add(graphic) +draw() } Graphic <|-- Circle Graphic <|-- CompositeGraphic CompositeGraphic --> Graphic
The Decorator Pattern attaches additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality.
Example:
// Component
class Coffee {
cost() {
return 5;
}
}
// Decorator
class MilkDecorator {
constructor(coffee) {
this.coffee = coffee;
}
cost() {
return this.coffee.cost() + 1;
}
}
// Usage
const myCoffee = new Coffee();
const milkCoffee = new MilkDecorator(myCoffee);
console.log(milkCoffee.cost()); // Output: 6
Diagram:
classDiagram class Coffee { +cost() } class MilkDecorator { +cost() } Coffee <|-- MilkDecorator MilkDecorator --> Coffee
The Facade Pattern provides a simplified interface to a complex subsystem. It defines a higher-level interface that makes the subsystem easier to use.
Example:
// Subsystem classes
class CPU {
freeze() {
console.log('Freezing CPU');
}
jump(position) {
console.log(`Jumping to position ${position}`);
}
execute() {
console.log('Executing instructions');
}
}
class Memory {
load(position, data) {
console.log(`Loading data into position ${position}`);
}
}
class HardDrive {
read(lba, size) {
console.log(`Reading ${size} bytes from LBA ${lba}`);
return 'data';
}
}
// Facade
class ComputerFacade {
constructor() {
this.cpu = new CPU();
this.memory = new Memory();
this.hardDrive = new HardDrive();
}
start() {
this.cpu.freeze();
this.memory.load(0, this.hardDrive.read(0, 1024));
this.cpu.jump(0);
this.cpu.execute();
}
}
// Usage
const computer = new ComputerFacade();
computer.start();
Diagram:
classDiagram class ComputerFacade { +start() } class CPU { +freeze() +jump(position) +execute() } class Memory { +load(position, data) } class HardDrive { +read(lba, size) } ComputerFacade --> CPU ComputerFacade --> Memory ComputerFacade --> HardDrive
The Flyweight Pattern reduces the cost of creating and manipulating a large number of similar objects. It achieves this by sharing as much data as possible with other similar objects.
Example:
// Flyweight
class Flyweight {
constructor(sharedState) {
this.sharedState = sharedState;
}
operation(uniqueState) {
console.log(`Shared: ${this.sharedState}, Unique: ${uniqueState}`);
}
}
// Flyweight Factory
class FlyweightFactory {
constructor() {
this.flyweights = {};
}
getFlyweight(sharedState) {
if (!this.flyweights[sharedState]) {
this.flyweights[sharedState] = new Flyweight(sharedState);
}
return this.flyweights[sharedState];
}
}
// Usage
const factory = new FlyweightFactory();
const flyweight1 = factory.getFlyweight('shared');
flyweight1.operation('unique1');
const flyweight2 = factory.getFlyweight('shared');
flyweight2.operation('unique2');
Diagram:
classDiagram class Flyweight { +operation(uniqueState) } class FlyweightFactory { +getFlyweight(sharedState) } FlyweightFactory --> Flyweight
The Proxy Pattern provides a surrogate or placeholder for another object to control access to it. This pattern is useful for implementing lazy initialization, access control, logging, and more.
Example:
// Real Subject
class RealImage {
constructor(filename) {
this.filename = filename;
this.loadFromDisk();
}
loadFromDisk() {
console.log(`Loading ${this.filename}`);
}
display() {
console.log(`Displaying ${this.filename}`);
}
}
// Proxy
class ProxyImage {
constructor(filename) {
this.filename = filename;
}
display() {
if (!this.realImage) {
this.realImage = new RealImage(this.filename);
}
this.realImage.display();
}
}
// Usage
const image = new ProxyImage('test.jpg');
image.display(); // Output: Loading test.jpg, Displaying test.jpg
image.display(); // Output: Displaying test.jpg
Diagram:
classDiagram class RealImage { +display() } class ProxyImage { +display() } ProxyImage --> RealImage
When implementing structural patterns in JavaScript, consider the following best practices and common pitfalls:
Best Practices:
Common Pitfalls:
Structural patterns play a vital role in designing robust and flexible JavaScript applications. By understanding and applying these patterns, developers can create systems that are easier to understand, maintain, and extend. Each pattern offers unique advantages and can be chosen based on the specific requirements of the application.