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
targetThe target object to be wrapped with
Proxy(can be any type of object, including a native array, a function, or even another proxy).
handlerAn object usually with functions as properties, where each function defines the behavior of the proxy
pwhen 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.
喜欢的话,留下你的评论吧~