• Ramotion /
  • Blog /
  • Mastering JavaScript Design Patterns: A Definitive Guide

Mastering JavaScript Design Patterns: A Definitive Guide

Master the art of clean, reusable JavaScript code with this comprehensive guide to design patterns! Learn how to structure your JavaScript projects.

Written by RamotionJun 11, 202417 min read

Last updated: Jun 11, 2024

Introduction

This is especially valuable for web app development agencies, where efficiency and maintainable code are crucial.

The main benefits of using design patterns in JavaScript are:

  • Reusability - Design patterns provide proven development paradigms that can be applied across projects
  • Reliability - Patterns have been refined over time and are well-tested solutions
  • Scalability - Patterns promote loose coupling and modular code which is easier to scale
  • Maintainability - Patterns make code more readable and easier to modify and debug
  • Organization - Patterns provide a standardized way to structure code that developers can easily understand

Why Use JavaScript Design Patterns?

JavaScript design patterns provide several benefits that improve code quality and development efficiency:

  1. Improves code reuse and flexibility - Design patterns are reusable solutions that can be applied to commonly occurring problems in software design. Using design patterns means you don't have to reinvent the wheel whenever you encounter a problem already solved.
  2. Makes code more readable and maintainable - Patterns promote clean, well-structured, and modular code. This makes code easier to understand, debug, and maintain. Other developers can more quickly comprehend the intent and structure of pattern-based code.
  3. Implements proven development practices - Patterns capture best practices and expertise that have developed over time in software engineering. They provide an elegant way to apply industry-standard techniques and approaches in your code.
  4. Facilitates collaboration - Since patterns provide a common vocabulary and shared understanding of solutions, they help teams work together more efficiently. Patterns enable developers to communicate more effectively about the architecture and design of systems.

Creational Patterns

Singleton Pattern

The Singleton pattern is a creational design pattern focused on managing the global application state. It restricts class instantiation to a single object instance across the entire application.

The key to implementing a singleton is limiting access to the constructor to prevent additional instances from being created. There are a few common ways to do this in JavaScript:

// Private constructor
function Singleton() {
  // private singleton instance
  let instance;

  // Getter method for accessing instance
  this.getInstance = () => {
    if (!instance) {
      instance = new Singleton();
    }
    return instance;
  }
}

// Immediately-invoked function expression 
let Singleton = (() => {
  let instance;

  function createInstance() {
    return {
      // methods
    };
  }

  return {
    getInstance: () => {
      if (!instance) {
        instance = createInstance();
      }
      return instance;
    }
  }  
})();

// ES6 class with static method
class Singleton {
  constructor() { 
    // ...
  }

  static getInstance() {
    if(!this.instance) {
      this.instance = new Singleton();
    }
    return this.instance;
  }
}
Copy

The key advantages of the Singleton pattern are:

  • Ensures only one instance exists, facilitating coordination and state management
  • Provides global point of access to the instance
  • Allows variable configuration through subclasses

Singletons are commonly used for things like managing shared state, utilities, logging, and configuration. But they can introduce issues like hidden dependencies, so should be used judiciously.

Factory Method Pattern

The factory method pattern provides an interface for creating objects in a superclass but allows subclasses to alter the type of objects that will be created.

The key idea behind this pattern is to abstract the creation of objects away from the code that uses them. Instead of directly instantiating objects, the code calls a "factory method" that handles the object creation task. 

This makes the code much more flexible since the factory method can be overridden in subclasses to create different types of objects.

Some key characteristics of the factory method pattern:

  • There is an abstract Creator class with a factory method that returns a Product object
  • ConcreteCreator subclasses can override the factory method to produce different types of Products
  • The Creator class does not need to know about the ConcreteProduct classes

Here is a basic example in JavaScript:

class Creator {
  factoryMethod() {
    // Default factory method
    return new ConcreteProduct1(); 
  }
}

class ConcreteCreator1 extends Creator {
  factoryMethod() {
    return new ConcreteProduct1();
  }
}

class ConcreteCreator2 extends Creator {
  factoryMethod() {
    return new ConcreteProduct2();
  }
}

class ConcreteProduct1 {}

class ConcreteProduct2 {}

// Client code
const creator1 = new ConcreteCreator1();
const product1 = creator1.factoryMethod(); 

const creator2 = new ConcreteCreator2();
const product2 = creator2.factoryMethod();
Copy

The key advantage is that the Creator classes can create different ConcreteProduct objects without knowing about their concrete classes. The factory method handles instantiation while still allowing customization via subclasses.

Builder Pattern

The Builder pattern is a creational design pattern that allows construction of complex objects step by step. The pattern allows you to produce different types and representations of an object using the same construction code.

The Builder pattern solves issues with large constructors that require many parameters to initialize an object. Instead of passing all the parameters into the constructor, you can provide a builder object that calls setter-like methods to configure the object step-by-step. After calling all the required methods, the builder then constructs the object.

Here's an example of a Builder pattern implementation in JavaScript:

class Car {
  constructor(model, wheels, engine, color) {
    this.model = model; 
    this.wheels = wheels;
    this.engine = engine; 
    this.color = color;
  }
}

class CarBuilder {
  constructor() {
    this.car = new Car();
  }

  setModel(model) {
    this.car.model = model;
    return this;
  }

  setWheels(wheels) {
    this.car.wheels = wheels;
    return this;
  }

  setEngine(engine) {
    this.car.engine = engine;
    return this; 
  }

  setColor(color) {
    this.car.color = color;
    return this;
  }

  build() {
    return this.car;
  }
}

const carBuilder = new CarBuilder();
const car = carBuilder.setModel('F150').setWheels(4).setColor('red').build();
Copy

The CarBuilder provides a step-by-step method for configuring and constructing a Car object. The client code creates a builder instance and uses setter-like methods to configure the car.

Finally, the build() method constructs and returns the car object. This allows constructing the car object step-by-step instead of passing all the parameters into the constructor.

Prototype Pattern

The Prototype pattern is a creational design pattern that allows cloning objects without coupling to their specific classes.

With the Prototype pattern, we create object instances by cloning a prototypical instance. The prototype serves as a template for creating objects.

To implement the Prototype pattern, we first create a prototype interface that declares the cloning method. All concrete prototype classes implement this interface and provide an implementation for the cloning method.

For example:

function Employee(name, age) {
  this.name = name;
  this.age = age;
}

Employee.prototype.clone = function() {
  return new Employee(this.name, this.age);
}

const emp1 = new Employee("John", 30);
const emp2 = emp1.clone(); 
Copy

Here, emp1 serves as a prototype, and emp2 is cloned from it. The prototype pattern allows the creation of new employee objects by cloning emp1 without coupling to the concrete Employee class.

The key benefit of the Prototype pattern is that it allows cloning objects independently of their concrete implementation and without tight coupling. The pattern delegates object creation to the actual objects themselves instead of directly instancing classes.

Structural Patterns

The Adapter pattern provides a way to enable incompatible interfaces to work together. It converts the interface of one class into an interface expected by clients.

The Adapter pattern allows classes with incompatible interfaces to work together by wrapping their interface around that of an already existing class. The adapter translates calls to its interface into calls to the other class's interface.

For example, imagine you have an existing class that fetches data from a third-party weather API. The class expects the API response in XML format. However, the API has been upgraded to use JSON instead of XML.

We can create an adapter that wraps the old weather API class. The adapter receives JSON calls, translates the JSON to XML, and then passes the XML to the old API class.

// Old API class 
class WeatherAPI {
  constructor() {
    this.endpoint = 'api.weather.com';
  }

  getWeather(location, xmlData) {
    // makes request and expects XML 
  }
}

// Adapter 
class WeatherAPIAdapter {
  constructor() {
    this.api = new WeatherAPI();
  }

  getWeather(location, jsonData) {
    // translate json data to xml 
    const xmlData = convertToXML(jsonData);
    
    return this.api.getWeather(location, xmlData);
  }
}

// Client calls adapter 
const api = new WeatherAPIAdapter();
const jsonData = {...};
api.getWeather('San Francisco', jsonData);
Copy

This allows the client to use the new JSON interface while leveraging the old XML-based API under the hood via the adapter. The adapter converts the data format for compatibility.

The Adapter pattern is useful when you reuse existing classes that don't share the same interface as your current code. It allows two incompatible interfaces to work together through an adapter that does the necessary translation.

Decorator Pattern

The Decorator pattern dynamically adds behaviors and responsibilities to an object without modifying the object's class. It provides an alternative to subclassing for extending functionality by wrapping an object inside another to provide additional behavior.

The key aspects of the Decorator pattern are:

  • It maintains the interface of the component it decorates to be interchangeable with other components.
  • It forwards requests to the component it wraps.
  • It can perform additional actions before/after forwarding a request.

This allows behaviors to be added, removed, or extended at runtime without affecting other objects in the system. The wrapped object isn't aware of the decorator.

Here's an example JavaScript implementation of the Decorator pattern:

// Interface
function Coffee() {}

Coffee.prototype.getCost = function() {
  return this.cost; 
};

Coffee.prototype.getDescription = function() {
  return this.description;
};


// Component
function SimpleCoffee() {
  this.cost = 10;
  this.description = 'Simple coffee';
}

SimpleCoffee.prototype = Object.create(Coffee.prototype);


// Decorator 1
function Milk(coffee) {
  this.coffee = coffee;
}

Milk.prototype.getCost = function() {
  return this.coffee.getCost() + 2;
};

Milk.prototype.getDescription = function() {
  return this.coffee.getDescription() + ', milk';
};


// Decorator 2 
function Whip(coffee) {
  this.coffee = coffee;
}

Whip.prototype.getCost = function() {
  return this.coffee.getCost() + 5;
};

Whip.prototype.getDescription = function() {
  return this.coffee.getDescription() + ', whip';
};


// Usage
let someCoffee = new SimpleCoffee();
console.log(someCoffee.getCost()); // 10
console.log(someCoffee.getDescription()); // Simple Coffee

someCoffee = new Milk(someCoffee); 
console.log(someCoffee.getCost()); // 12
console.log(someCoffee.getDescription()); // Simple Coffee, milk

someCoffee = new Whip(someCoffee);
console.log(someCoffee.getCost()); // 17 
console.log(someCoffee.getDescription()); // Simple Coffee, milk, whip
Copy

This allows us to dynamically add behaviors like milk and whip to a simple coffee order at runtime by wrapping it with decorators.

Facade Pattern

The Facade pattern provides a simplified interface to a complex system of classes, libraries, or frameworks. It exposes a higher-level interface that makes the subsystem easier to use and understand.

Simplifying Complex Systems

Complex systems like libraries and frameworks often contain a large number of classes and interactions that can be overwhelming for developers. The Facade pattern hides this complexity behind a simple, easy-to-understand interface.

For example, a video conversion library may provide classes for transmuxing, transcoding, tagging metadata, and more. The Facade would provide simple methods like convertFile() so developers don't need to know about the lower-level complexity.

Facade Implementation

Here is an example of Facade implementation in JavaScript for a complex video conversion library:

class VideoConverterFacade {
  constructor() {
    this.transmuxer = new Transmuxer(); 
    this.transcoder = new Transcoder();
    this.tagger = new MetadataTagger();
  }

  convertFile(inputFile, outputFile) {
    this.transmuxer.transmux(inputFile);
    this.transcoder.transcode(inputFile, outputFile);
    this.tagger.addMetadata(outputFile);
  }
}

// Client code 
const converter = new VideoConverterFacade();
converter.convertFile('fileA.mov', 'fileB.mp4');
Copy

The Facade provides a simple convertFile() method for interacting with the underlying video conversion classes. This simplifies the client code and allows it to use the Facade interface.

Composite Pattern

The composite pattern treats individual objects and compositions of objects uniformly. This allows clients to treat single objects and compositions uniformly, simplifying the client code.

The key to the composite pattern is an abstract base class that represents primitives and their containers. For example, a graphical interface library could have primitive objects such as Text, Line, Circle, and Rectangle and container objects such as Figures and Panels. 

The base Component class would declare common methods like Draw implemented by all primitive and container classes.

This enables clients to treat all objects in the library uniformly by simply calling myComponent.Draw(). The component decides whether to draw itself as a primitive or loop through its child components for rendering.

Here's an example implementation in JavaScript:

class Component {
  Draw() {
    // abstract
  } 
}

class Circle extends Component {
  constructor(radius) {
    this.radius = radius;
  }

  Draw() {
    console.log(`Draw circle of radius ${this.radius}`); 
  }
}

class Composite extends Component {
  constructor() {
    this.children = []; 
  }

  Add(component) {
    this.children.push(component);
  }

  Remove(component) {
    // remove component from children
  }

  Draw() {
    for(let child of this.children) {
      child.Draw(); 
    }
  }
}
Copy

This allows the client to treat both primitive Circle objects and Composite containers uniformly:

// Create primitives
let circle1 = new Circle(10);
let circle2 = new Circle(20);

// Create composite container
let composite = new Composite();
composite.Add(circle1); 
composite.Add(circle2);

// Draw all components
composite.Draw();
Copy

The key benefit is that the client code can simply call Draw() on any component, without having to know or care whether it is a primitive shape or a composite container.

Flyweight Pattern

The flyweight pattern is a structural design pattern focused on minimizing memory usage by sharing data between similar objects. It removes the extrinsic, contextual state from objects and moves it to external data structures. What remains are intrinsic, shareable flyweight objects.

By extracting out the mutable, context-specific state and behaviors from the objects, we can significantly reduce the memory footprint of an application. The same flyweight objects are reused rather than creating new ones each time.

For example, imagine we have an e-commerce site with product items. Each product has an image, name, description, and price. The image, name, and description can be shared across product instances as they don't change. But the price is external, contextual data that varies.

class Product {
  constructor(name, description, imageUrl) {
    this.name = name;
    this.description = description; 
    this.imageUrl = imageUrl;
  }
}

class ProductFlyweight {
  constructor(sharedProducts) {
    this.products = sharedProducts;
  }
  
  createProduct(name, description, imageUrl, price) {
    let product = this.products.find(p => p.name === name);
    
    if(!product) {
      product = new Product(name, description, imageUrl);
      this.products.push(product);
    } 
    
    return {
      ...product,
      price 
    };
  }
}

// Usage

const flyweight = new ProductFlyweight([]);

const prod1 = flyweight.createProduct('Toaster', 'A kitchen appliance...', '/toaster.jpg', 29.95);
const prod2 = flyweight.createProduct('Toaster', 'A kitchen appliance...', '/toaster.jpg', 39.95); 

console.log(product1 === prod2); // true - points to same object
Copy

By extracting the extrinsic price data outside the Product, we can reuse the same Product instance in memory rather than creating new ones unnecessarily. This significantly reduces memory usage when we have thousands of product instances.

Proxy Pattern

The Proxy pattern provides a surrogate or placeholder object that controls access to another object, referred to as the subject.

A proxy object has the same interface as the subject so that it can be used as a substitute. The proxy handles the access and controls when and if the real subject is called upon.

Some common uses of the Proxy pattern:

  • Lazy initialization - Creating the real object on first use to improve performance
  • Access control - Managing permissions on who can use the subject
  • Local execution of remote service - Providing a local proxy for a remote object to reduce latency
  • Logging - Tracking access to the subject
  • Caching - Providing a cached copy to avoid repeat operations
  • Optimization - Deferring expensive operations until they are needed

Here is a simple example of using the Proxy pattern in JavaScript:

class ExpensiveObject {

  constructor() {
    this.heavyComputation1 = //...
    this.heavyComputation2 = //...
  }

  process() {
    return this.heavyComputation1 + this.heavyComputation2
  }

}

class ExpensiveObjectProxy {

  constructor() {
    this.subject = null;
  }

  getSubject() {
    if (!this.subject) {
       this.subject = new ExpensiveObject();
    }
    return this.subject;
  }

  process() {
    return this.getSubject().process(); 
  }

}

const proxy = new ExpensiveObjectProxy();
proxy.process(); // Initializes real object only when first called
Copy

This implements a simple proxy that defers creating the real expensive object until the first method call. The rest of the code can use the proxy instead of the real subject without extra effort.

Behavioral Patterns

Behavioral Patterns

The observer pattern is a behavioral design pattern that defines a one-to-many dependency between objects so that all of its dependents are notified and updated automatically when one object changes state.

It allows objects to subscribe to state changes and get notified when an event occurs. This pattern promotes loose coupling by ensuring the objects sending notifications know nothing about the objects receiving them.

Subscribing to State Changes

The observer pattern consists of two actor types:

  1. Subject - maintains a list of observers, facilitates adding/removing observers, and broadcasts notifications to observers when state changes
  2. Observer - provides an update interface to receive notifications from subjects

This allows observers to subscribe to subjects. When the subject's state changes, it loops through all observers and calls their update method. The observer can then query the subject if needed to synchronize its state with the subject's state.

Example Observer Implementation

Here is a simple example of implementing the observer pattern in JavaScript:

class Subject {
  constructor() {
    this.observers = []; 
  }

  subscribe(observer) {
    this.observers.push(observer);
  }

  unsubscribe(observer) {
    this.observers = this.observers.filter(obs => obs !== observer);
  }
 
  notify(data) {
    this.observers.forEach(observer => observer.update(data));
  }
}

class Observer {
  constructor(state) {
    this.state = state;
  }

  update(data) {
    this.state = data;
    console.log(`State updated to ${this.state}`); 
  }
}

const subject = new Subject();

const observer1 = new Observer(1);
subject.subscribe(observer1);

const observer2 = new Observer(2);
subject.subscribe(observer2);

subject.notify('New state');
Copy

This allows the subject to update all subscribed observers when its state changes. The observers simply implement the update method to handle notifications from the subject.

Command Pattern

The command pattern encapsulates a request as an object, letting you parameterize clients with different requests, queue or log requests, and support undoable operations.

Some key characteristics of the command pattern:

  • Decouples the object that invokes the operation from the one that knows how to perform it. The sender passes a command object to the invoker object. The invoker calls back the command object to operate.
  • Commands are first-class objects. They can be manipulated and extended like any other object.
  • You can assemble commands into a composite command. Invoking a composite command invokes all of its component commands.
  • Commands may support undo. The Command interface can have an unexecute() method that reverses the effects of a previous call to execute().
  • It can be used for logging changes so they can be reapplied in case of a system crash.

Here is an example of implementing a simple command in JavaScript:

// Command interface
function Command(receiver) {
  this.receiver = receiver;
}

Command.prototype.execute = function() {
  this.receiver.action(); 
}

// Receiver 
function Light {
  
  this.action = function() {
    console.log("Turning on the light");
  }

}

// Invoker
function Switch() {
  
  this.storeAndExecute = function(cmd) {
    this.command = cmd;
    this.command.execute();
  }

}

// Client 
let light = new Light();
let switchUp = new Command(light); 

let mySwitch = new Switch();
mySwitch.storeAndExecute(switchUp);
Copy

This allows the switch to invoke the command to turn on the light while decoupling the objects. The command encapsulates the request to act as the receiver.

Strategy Pattern

The Strategy pattern defines a family of algorithms that encapsulates each one and makes them interchangeable. This pattern lets the algorithm vary independently from clients that use it.

Here is an example of how the Strategy pattern works:

// Strategy interface
const Strategy = function() {}; 

Strategy.prototype.execute = function() {};

// Concrete strategies
const ConcreteStrategyA = function() {};

ConcreteStrategyA.prototype = new Strategy(); 

ConcreteStrategyA.prototype.execute = function(){
  console.log('Called ConcreteStrategyA.execute()');
}

const ConcreteStrategyB = function() {};

ConcreteStrategyB.prototype = new Strategy();

ConcreteStrategyB.prototype.execute = function(){
  console.log('Called ConcreteStrategyB.execute()');  
}

// Context 
const Context = function(strategy){
  this.strategy = strategy;
}

Context.prototype.setStrategy = function(strategy) {
  this.strategy = strategy;
}

Context.prototype.executeStrategy = function(){
  this.strategy.execute();  
}

// Usage
const context = new Context(new ConcreteStrategyA());
context.executeStrategy(); // "Called ConcreteStrategyA.execute()"

context.setStrategy(new ConcreteStrategyB()); 
context.executeStrategy(); // "Called ConcreteStrategyB.execute()"
Copy

The key aspects are:

  • The Strategy interface defines an execute() method common to all supported algorithms.
  • ConcreteStrategy implementations encapsulate specific algorithms.
  • The Context accepts a Strategy object and invokes its execute() method.
  • This allows the actual strategy to vary independently of the context using it.

The Strategy pattern enables selecting an algorithm at runtime, avoiding conditional statements in the client code. It adheres to the open/closed principle, allowing new strategies to be added without modifying existing code.

Iterator Pattern

The iterator pattern provides a sequence interface for traversing elements of an aggregate object without exposing the underlying representation. This pattern is useful when you need to access the elements of an object sequentially without knowing its underlying structure.

Some key aspects of the iterator pattern:

  • It provides a standard way to traverse objects sequentially.
  • It decouples algorithms from the objects they operate on.
  • It simplifies code by extracting traversal behavior into its class.

For example, you can use an iterator to loop through a collection:

// Iterator
function Iterator(items) {
  this.index = 0;
  this.items = items;
}

Iterator.prototype = {
  hasNext: function() {
    return this.index < this.items.length; 
  },
  next: function() {
    return this.items[this.index++];
  }
}

// Usage
const iterator = new Iterator([1, 2, 3]);

while (iterator.hasNext()) {
  console.log(iterator.next()); 
}
Copy

This allows you to loop through the items sequentially without knowing anything about the underlying data structure. The iterator handles the traversal logic for you.

The iterator pattern is useful whenever you need to traverse a collection of objects in a standardized way while hiding the implementation details of the collection itself. It provides cleaner code and looser coupling between algorithms and data structures. Mediator Pattern The mediator pattern centralizes complex communication and control logic between objects in a system. It promotes loose coupling by keeping objects from referring to each other explicitly, and it allows their interaction to be varied independently.

Here's how it works:

  1. A mediator object is created which encapsulates the interaction logic between other objects
  2. Other objects in the system communicate only with the mediator, not directly with each other
  3. This reduces the dependencies between objects, making them loosely coupled
  4. The mediator has methods the other objects can call to trigger interactions and communication
  5. The mediator handles all the logic of deciding when and how to interact with each object
  6. Objects just inform the mediator when notable events occur that may be of interest
  7. The mediator can access and invoke methods on the objects to implement centralized logic

This pattern promotes reusability and maintainability by decoupling components. Complex communication logic must only be defined in one place - the mediator. This makes it easy to reuse individual components because they don't depend directly on the communication scheme.

Practical Applications and Implementations

Implementing Singleton and Factory Patterns in a Web App

The Singleton and Factory patterns are commonly used together in web applications. The Singleton ensures only one instance of an object is created, while the Factory generates the actual object instance.

For example, you may have a UserService class that connects to your database and handles user accounts. You want to ensure only one UserService instance exists (Singleton) but abstract away the creation logic (Factory).

class UserService {
  // Singleton logic
}

class UserServiceFactory {

  static getInstance() {
    // Return singleton instance 
  }

}
Copy

Now components can get the singleton UserService instance via the Factory instead of creating it directly. This encapsulates the instantiation logic and guarantees a single UserService.

Using Structural Patterns in UI/UX Design

Structural patterns like Decorator, Flyweight, and Composite apply to UI/UX development.

The Decorator pattern allows dynamically adding behaviors to UI components. For example, you can create a base Button component and then "decorate" it with behaviors for hover effects, animations, etc.

The Flyweight pattern minimizes memory usage by sharing UI elements. For example, you could have a Pool of common Icon Flyweight objects that components reference instead of having their own Icon instances.

The Composite pattern treats individual UI elements and compositions uniformly. For example, you can have primitive elements like Buttons that compose into complex Components with the same base interface.

Behavioral Patterns in Event-Driven Systems

Behavioral patterns shine in event-driven systems like browser apps. The Observer pattern is essential - UI components subscribe to event notifications to update accordingly.

The Command pattern encapsulates actions triggered by UI events into classes with execute() methods. For example, a Button click could execute a NetworkCommand to send data.

Strategies define interchangeable algorithms that run on UI events. For instance, an AutoSaveStrategy could define how/when data is saved based on events. The Iterator pattern abstracts traversal of data collections, enabling easy pagination in UI lists. So behavioral patterns help manage events, communication, workflows, and more in UIs.

Refactoring Legacy Code with Design Patterns

Legacy codebases can often become unstructured mess over time as requirements change and new features are added. Using design patterns is an effective way to refactor and improve old legacy code.

There are a few key benefits to refactoring legacy code with patterns:

  • Improved Structure: Patterns like MVC can separate concerns and enforce structure on sprawling code.
  • Increased Clarity: Applying patterns makes the intent and purpose of the code more clear. Other developers can more easily reason about the code.
  • Simplified Maintenance: Well-structured and organized code is much easier to maintain and extend over time.
  • Reusable Abstractions: Patterns create reusable abstractions that can eliminate duplicated logic.

Let's look at an example of refactoring legacy UI code by applying the MVC pattern:

// Legacy UI Code
const appState = {
  data: [],
  template: ''  
};

function render() {
  const template = createTemplate(appState.data); 
  document.body.innerHTML = template;
}

function fetchData() {
  appState.data = getData();
  render(); 
}

fetchData();

// Refactored with MVC

// Model
class Model {
  data = [];

  fetchData() {
    this.data = getData(); 
  }
}

// View 
class View {
  constructor(model) {
    this.model = model;
  }

  render() {
    const template = createTemplate(this.model.data);
    document.body.innerHTML = template;
  }
}

// Controller
class Controller {
  constructor(model, view) {
    this.model = model;
    this.view = view;
  }

  init() {
    this.model.fetchData();
    this.view.render();
  }
}

const app = new Controller(new Model(), new View());
app.init();
Copy

The refactored MVC version is much more structured, encapsulated, and maintainable. This pattern can be applied to large legacy UIs to improve their design.

Optimizing Performance with Flyweight

The Flyweight design pattern is an optimization technique that minimizes memory usage by sharing as much data as possible between similar objects.

In JavaScript, the Flyweight pattern revolves around caching and reusing existing objects instead of creating new ones. The key is to identify "flyweight" objects that can be shared and avoid duplicating them unnecessarily.

For example, imagine we have an application that needs to create many objects representing users. Each user object contains properties like name, age, address etc.

// Without flyweight
const user1 = {
  name: 'John',
  age: 20,
  address: '123 Main St' 
}

const user2 = {
  name: 'Jane',
  age: 25,
  address: '456 Park Ave'
}
Copy

This works fine but creates a new user object each time, which can be memory intensive. With flyweight, we could create a User factory that maintains a cache of user objects:

// With flyweight

const User = {
  cache: {},
  create: function(name, age, address) {
    // If user exists in cache, return it
    if (User.cache[address]) {
      return User.cache[address];
    }
    
    // Create new user object
    const newUser = {
      name, 
      age,
      address
    };
    
    // Add to cache for reuse
    User.cache[address] = newUser;
    
    return newUser;
  }
}

const user1 = User.create('John', 20, '123 Main St'); 
const user2 = User.create('Jane', 25, '456 Park Ave');
Copy

Now, if user1 and user2 have the same address, they point to the same object instance. This avoids creating duplicate objects unnecessarily.

The Flyweight pattern dramatically improves memory usage by minimizing the number of objects created. It's most applicable when you need to create many similar objects. Reusing flyweight objects reduces the application's overall memory footprint.

Optimizing Performance with Proxy

The Proxy design pattern provides a placeholder for an object, deferring full object creation until needed. This allows proxies to stand in for expensive objects to instantiate or access.

Some key benefits of using the Proxy pattern:

  • Deferred object creation—Proxies can defer instantiating objects until they are actually needed, avoiding unnecessary resource usage.
  • Improved load times—By only loading objects on demand, proxies can significantly improve initial load times for resource-intensive apps and sites.
  • Additional abstraction layer - The proxy is an intermediary between the client and the real object, allowing for additional functionality like logging, caching, access control, etc.

Here is an example Proxy implementation in JavaScript:

class ExpensiveObject {

  constructor() {
    // Simulate expensive construction
    console.log('Creating ExpensiveObject');
  }

  process() {
    // Perform resource intensive processing
    console.log('Processing request'); 
  }

}

class ExpensiveObjectProxy {

  constructor() {
    this.realObject = null;
  }
  
  process() {
    if (!this.realObject) {
      this.realObject = new ExpensiveObject();
    }

    return this.realObject.process(); 
  }

}

// Client code
const proxy = new ExpensiveObjectProxy();
proxy.process(); // Only constructs ExpensiveObject when first called
Copy

In this example, the ExpensiveObjectProxy defers creating the real ExpensiveObject until the process method is called. This avoids constructing the expensive object until it is actually needed.

The Proxy pattern is a powerful way to optimize performance by deferring costly object creation and access. It enables lazy instantiation and can improve initial load times.

Share: