Youth Camp | "Front-end Design Patterns in Practice" Notes

发表于 2022-02-09 20:30 2083 字 11 min read

cos avatar

cos

FE / ACG / 手工 / 深色模式强迫症 / INFP / 兴趣广泛养两只猫的老宅女 / remote

本文介绍了JavaScript中常用的设计模式,包括单例模式、观察者模式(发布订阅)、原型模式、代理模式和迭代器模式,并结合浏览器和前端框架(如React)的实际应用场景进行说明。文章强调设计模式并非万能解决方案,其真正价值在于实际项目中的应用与实践,建议通过学习优秀开源项目来深入理解与掌握。

This article has been machine-translated from Chinese. The translation may contain inaccuracies or awkward phrasing. If in doubt, please refer to the original Chinese version.

What Are Design Patterns

Design patterns are solution models for common problems in software design. They are summaries of historical experience and are not tied to any specific language.

Design patterns are roughly divided into 23 patterns:

  • Creational — How to create objects efficiently and flexibly
  • Structural — How to flexibly assemble objects into larger structures
  • Behavioral — Responsible for efficient communication and division of responsibilities between objects

Design Patterns in Browsers

Singleton Pattern

Singleton Pattern — There exists a globally accessible object, and any access or modification from anywhere is reflected on this object.

The most commonly used example is the browser’s window object, which provides encapsulation for browser operations and is commonly used for caching and global state management.

Implementing Request Caching with the Singleton Pattern

Using the Singleton Pattern to implement request caching: for identical URL requests, we want the second request to reuse previous values.

First, create a Request class with a static method getInstance for creating a singleton object. The actual request operation is the request method, which sends a request to a URL. If the URL exists in the cache, it returns immediately; otherwise, it caches it in the singleton object. You can see this uses syntax from the previous lesson.

import { api } from './utils';
export class Request {
  static instance: Request;
  private cache: Record<string, string>;
  constructor() {
    this.cache = {};
  }
  static getinstance() {
    if (this.instance) {
      return this.instance;
    }
    this.instance = new Request(); // No previous request exists, initialize the singleton
    return this.instance;
  }
  public async request(url: string) {
    if (this.cache[url]) {
      return this.cache[url];
    }
    const response = await api(url);
    this.cache[url] = response;

    return response;
  }
}

In practice, it’s used as follows: create the singleton object using the getInstance static method and compare execution times.

PS: Testing here uses Jest, with some expect APIs. You can learn about their usage through the documentation.

// Without pre-requesting, test the time.
test('should response more than 500ms with class', async () => {
  const request = Request.getinstance(); // Get/create a singleton object (creates one if it doesn't exist)
  const startTime = Date.now();
  await request.request('/user/1');
  const endTime = Date.now();

  const costTime = endTime - startTime;
  expect(costTime).toBeGreaterThanOrEqual(500);
});
// First make a request, then test the second request's time
test('should response quickly second time with class', async () => {
  const request1 = Request.getinstance();
  await request1.request('/user/1');

  const startTime = Date.now(); // Test this portion's time
  const request2 = Request.getinstance();
  await request2.request('/user/1');
  const endTime = Date.now(); //

  const costTime = endTime - startTime;
  expect(costTime).toBeLessThan(50);
});

In JavaScript, we don’t need to use a class for this, because traditional languages can’t export standalone methods — they can only export classes.

// Without class? More concise
import { api } from './utils';
const cache: Record<string, string> = {};
export const request = async (url: string) => {
  if (cache[url]) {
    // Same as in the class version
    return cache[url];
  }
  const response = await api(url);

  cache[url] = response;
  return response;
};
// Usage: you can see this approach also follows the Singleton Pattern but is more concise.
test('should response quickly second time', async () => {
  await request('/user/1');
  const startTime = Date.now(); // Test this portion's time
  await request('/user/1');
  const endTime = Date.now();

  const costTime = endTime - startTime;
  expect(costTime).toBeLessThan(50);
});

Publish-Subscribe Pattern (Observer Pattern)

A widely used pattern that notifies subscribers when the subscribed object changes. Common scenarios are abundant, from decoupling system architecture to business implementation patterns, email subscriptions, etc. Similar to adding event listeners.

Implementing User Online Subscription with the Publish-Subscribe Pattern

Here’s a practical example: through this pattern, we can implement mutual user subscriptions and call the corresponding notification function when a user comes online.

As shown, a User class is created. In the constructor, the initial status is set to offline. It has a followers array of objects containing {user, notify function}. Each time this user comes online, it traverses the followers to notify them.

type Notify = (user: User) => void;
export class User {
  name: string;
  status: 'offline' | 'online'; // Status: offline/online
  followers: { user: User; notify: Notify }[]; // Array of subscriptions, including the user and their online notification function
  constructor(name: string) {
    this.name = name;
    this.status = 'offline';
    this.followers = [];
  }
  subscribe(user: User, notify: Notify) {
    user.followers.push({ user, notify });
  }
  online() {
    // User comes online, call subscription functions
    this.status = 'online';
    this.followers.forEach(({ notify }) => {
      notify(this);
    });
  }
}

Test function: still using Jest, creating mock subscription functions for testing.

test('should notify followers when user is online for multiple users', () => {
  const user1 = new User('user1');
  const user2 = new User('user2');
  const user3 = new User('user3');
  const mockNotifyUser1 = jest.fn(); // Function to notify user1
  const mockNotifyUser2 = jest.fn(); // Function to notify user2
  user1.subscribe(user3, mockNotifyUser1); // 1 subscribes to 3
  user2.subscribe(user3, mockNotifyUser2); // 2 subscribes to 3
  user3.online(); // 3 comes online, calls mockNotifyUser1 and mockNotifyUser2
  expect(mockNotifyUser1).toBeCalledWith(user3);
  expect(mockNotifyUser2).toBeCalledWith(user3);
});

Design Patterns in JavaScript

Prototype Pattern

You might recall a common JavaScript language feature: the prototype chain. The Prototype Pattern refers to copying an existing object to create a new one, which offers better performance for very large objects (compared to direct creation). Commonly used for object creation in JavaScript.

Creating Users for Online Subscription with the Prototype Pattern

First, create a prototype. Notice this prototype doesn’t define a constructor compared to the previous version.

// Prototype Pattern -- of course we need a prototype
const baseUser: User = {
  name: '',
  status: 'offline',
  followers: [],
  subscribe(user, notify) {
    user.followers.push({ user, notify });
  },
  online() {
    // User comes online, call subscription functions
    this.status = 'online';
    this.followers.forEach(({ notify }) => {
      notify(this);
    });
  },
};

Export a function that creates objects based on this prototype. The function accepts a name parameter and uses Object.create() to create a new object from the prototype, then adds or modifies properties on top of it.

// Then export a function that creates objects based on this prototype
export const createUser = (name: string) => {
  const user: User = Object.create(baseUser);
  user.name = name;
  user.followers = [];
  return user;
};

Usage: you can see that new User has been replaced with createUser.

test('should notify followers when user is online for user prototypes', () => {
  const user1 = createUser('user1');
  const user2 = createUser('user2');
  const user3 = createUser('user3');
  const mockNotifyUser1 = jest.fn(); // Function to notify user1
  const mockNotifyUser2 = jest.fn(); // Function to notify user2
  user1.subscribe(user3, mockNotifyUser1); // 1 subscribes to 3
  user2.subscribe(user3, mockNotifyUser2); // 2 subscribes to 3
  user3.online(); // 3 comes online, calls mockNotifyUser1 and mockNotifyUser2
  expect(mockNotifyUser1).toBeCalledWith(user3);
  expect(mockNotifyUser2).toBeCalledWith(user3);
});

Proxy Pattern

Allows custom control over how objects are accessed and permits additional processing before and after updates. Commonly used for monitoring, proxy tools, front-end frameworks, etc. JavaScript has a built-in proxy object: Proxy(), which is also detailed in the Proxy chapter of “JavaScript: The Definitive Guide.”

Implementing User Status Subscription with the Proxy Pattern

Using the Observer Pattern example from before, we can optimize it with the Proxy Pattern so that the online function only does one thing: change the status to online.

type Notify = (user: User) => void;
export class User {
  name: string;
  status: 'offline' | 'online'; // Status: offline/online
  followers: { user: User; notify: Notify }[]; // Subscription array, including user and notification function
  constructor(name: string) {
    this.name = name;
    this.status = 'offline';
    this.followers = [];
  }
  subscribe(user: User, notify: Notify) {
    user.followers.push({ user, notify });
  }
  online() {
    // User comes online
    this.status = 'online';
    // this.followers.forEach( ({notify}) => {
    //     notify(this);
    // });
  }
}

Create a Proxy for User: ProxyUser

Proxy function description

target

The target object to be wrapped with Proxy (can be any type of object, including a native array, a function, or even another proxy).

handler

An object usually with functions as properties, where each function defines the behavior of the proxy p when various operations are performed.

// Create a proxy to monitor status changes
export const createProxyUser = (name: string) => {
  const user = new User(name); // Normal user
  // Proxied object
  const proxyUser = new Proxy(user, {
    set: (target, prop: keyof User, value) => {
      target[prop] = value;
      if (prop === 'status') {
        notifyStatusHandlers(target, value);
      }
      return true;
    },
  });
  const notifyStatusHandlers = (user: User, status: 'online' | 'offline') => {
    if (status === 'online') {
      user.followers.forEach(({ notify }) => {
        notify(user);
      });
    }
  };
  return proxyUser;
};

Iterator Pattern

Accesses data in a collection without exposing data types. Commonly used when data structures contain multiple data types (lists, trees, etc.), providing a universal operation interface.

Iterating All Components with for…of

This uses Symbol.iterator, which can be used by for...of loops.

Define a list queue. Each time, take a node from the front; if that node has children, add them all to the back of the queue. Each call to next returns a node. See the code for details.

class MyDomElement {
  tag: string;
  children: MyDomElement[];
  constructor(tag: string) {
    this.tag = tag;
    this.children = [];
  }
  addChildren(component: MyDomElement) {
    this.children.push(component);
  }
  [Symbol.iterator]() {
    const list = [...this.children];
    let node;
    return {
      next: () => {
        while ((node = list.shift())) {
          // Each time take a node from the front; if it has children, add them to the back
          node.children.length > 0 && list.push(...node.children);
          return { value: node, done: false };
        }
        return { value: null, done: true };
      },
    };
  }
}

Usage scenario: iterate all child elements in body using for…of

test('can iterate root element', () => {
  const body = new MyDomElement('body');
  const header = new MyDomElement('header');
  const main = new MyDomElement('main');
  const banner = new MyDomElement('banner');
  const content = new MyDomElement('content');
  const footer = new MyDomElement('footer');

  body.addChildren(header);
  body.addChildren(main);
  body.addChildren(footer);

  main.addChildren(banner);
  main.addChildren(content);

  const expectTags: string[] = [];
  for (const element of body) {
    // Iterate all elements of body, should include children of main
    if (element) {
      expectTags.push(element.tag);
    }
  }

  expect(expectTags.length).toBe(5);
});

Design Patterns in Front-end Frameworks (React, Vue…)

Proxy Pattern

Different from the Proxy discussed earlier.

Vue Component Counter

<template>
  <button @click="count++">count is:{{ count }}</button>
</template>
<script setup lang="ts">
  import { ref } from 'vue';
  const count = ref(0);
</script>

In the above code, why does count change with clicks? This brings us to how front-end frameworks proxy DOM operations:

Changing DOM properties -> View update

Changing DOM properties -> Update Virtual DOM -Diff-> View update

The following is a front-end framework’s proxy for the DOM. Through the hooks it provides, you can perform operations before and after updates:

<template>
  <button @click="count++">count is:{{ count }}</button>
</template>
<script setup lang="ts">
  import { ref, onBeforeUpdate, onUpdated } from 'vue';
  const count = ref(0);
  const dom = ref<HTMLButtonElement>();
  onBeforeUpdate(() => {
    console.log('Dom before update', dom.value?.innerText);
  });
  onUpdated(() => {
    console.log('Dom after update', dom.value?.innerText);
  });
</script>

Composite Pattern

Multiple objects can be used together, or individual objects can be used independently. Commonly applied to front-end components. The most classic example is React’s component structure:

React Component Structure

Still the counter example:

export const Count = () => {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount((count) => count + 1)}>count is: {count}</button>;
};

This Count component can be rendered independently or rendered within App — the latter is a form of composition.

function App() {
    return (
        <div className = "App">
         <Header />
            <Count />
            <Footer />
        </div>
    );
}

Summary and Reflections

Here are some conclusions from the instructor:

Design patterns are not a silver bullet. Summarizing abstract patterns sounds simple, but applying abstract patterns to real scenarios is very difficult. The multi-paradigm nature of modern programming languages brings more possibilities. We should learn design patterns from truly excellent open-source projects and practice continuously.

This lesson covered common design patterns in browsers and JavaScript, including Singleton, Observer, Prototype, Proxy, Iterator patterns, and also explained what design patterns are useful for. In my view, learning design patterns from real projects is indeed one of the better approaches.

Most of the content cited in this article comes from Wu Lining’s lecture.

喜欢的话,留下你的评论吧~

© 2020 - 2026 cos @cosine
Powered by theme astro-koharu · Inspired by Shoka