banner
cos

cos

愿热情永存,愿热爱不灭,愿生活无憾
github
tg_channel
bilibili

Youth Training Camp | "Application of Front-end Design Patterns" Notes

What is a Design Pattern#

A design pattern is a solution model for common problems in software design, summarizing historical experience and is independent of specific languages.

There are roughly 23 design patterns.

  • Creational — How to efficiently and flexibly create an object.
  • 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#

The Singleton pattern — there exists a globally accessible object, and any access and modification from anywhere will reflect on this object.

The most commonly used is actually the window object in the browser, which encapsulates operations on the browser and is often used for caching and global state management.

Implementing Request Caching with Singleton Pattern#

Using the Singleton pattern to implement request caching: for the same URL request, we hope to reuse some previous values when sending the request the second time.

First, create a Request class, which contains a static method getInstance to create a singleton object. The actual request operation is done by the request method, which sends a request to the URL. If the URL exists in the cache, it returns directly; otherwise, it caches it in the singleton object. Below is the syntax discussed in 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();  // Initialize the singleton as there hasn't been a request before
        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 is used as follows: create the singleton object using the getInstance static method and test the execution time for comparison.

ps: The tests here are conducted using Jest, which utilizes some expect APIs. You can refer to the documentation for their usage.

// No pre-request, testing its time.
test('should response more than 500ms with class', async() => {
    const request = Request.getInstance();  // Get/create a singleton object (create if it hasn't been created before)
    const startTime = Date.now();
    await request.request('/user/1');
    const endTime = Date.now();

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

    const startTime = Date.now();   // Test the time for this part
    const request2 = Request.getInstance();
    await request2.request('/user/1');
    const endTime = Date.now();		//

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

In JavaScript, we can also write it without a class, as traditional languages cannot export an independent method, only a class.

// Without class? It can be more concise
import {api} from './utils';
const cache: Record<string,string> = {};
export const request = async (url:string) => {
    if(cache[url]) {    // Consistent with the class
        return cache[url];
    }
    const response = await api(url);

    cache[url] = response;
    return response;
};
// Usage shows that this method also conforms to the Singleton pattern but is more concise.
test('should response quickly second time', async() => {
    await request('/user/1');
    const startTime = Date.now();   // Test the time for this part
    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. There are many common scenarios, from decoupling between system architectures to implementations in business, email subscriptions, etc. It is similar to adding events.

Implementing User Online Subscription with Publish-Subscribe Pattern#

An example of practical application: through this pattern, we can implement mutual subscriptions among users, calling the corresponding notification function when the user comes online.

As shown, a User class is created, with the initial status set to offline in the constructor. It has an array of followers objects, which includes all {user, notify function} that the user subscribes to. Each time the user comes online, it traverses its 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 subscribers, including users and their notification functions when online
    constructor(name: string) {
        this.name = name;
        this.status = "offline";
        this.followers = [];
    }
    subscribe(user:User, notify: Notify) {
        user.followers.push({user, notify});
    }
    online() { // The user comes online and calls its subscription functions
        this.status = "online";
        this.followers.forEach( ({notify}) => {
            notify(this);
        });
    }
}

Testing function: still using Jest, creating fake notification 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 subscribed to 3
   user2.subscribe(user3, mockNotifyUser2); // 2 subscribed to 3
   user3.online();  // 3 comes online, calling mockNotifyUser1 and mockNotifyUser2
   expect(mockNotifyUser1).toBeCalledWith(user3);
   expect(mockNotifyUser2).toBeCalledWith(user3);
});

Design Patterns in JavaScript#

Prototype Pattern#

One can think of a common language feature in JavaScript: the prototype chain. The prototype pattern refers to copying an existing object to create a new object, which can have better performance when the object is very large (compared to direct creation). It is commonly used for object creation in JS.

Creating Users in Online Subscription with Prototype Pattern#

First, create a prototype. It can be seen that this prototype does not define a constructor compared to the previous one.

// Prototype pattern, of course, there must be a prototype
const baseUser:User = { 
    name: "",
    status: "offline",
    followers: [],
    subscribe(user, notify) {
        user.followers.push({user, notify});
    },
    online() { // The user comes online and calls its subscription functions
        this.status = "online";
        this.followers.forEach( ({notify}) => {
            notify(this);
        });
    }
}

Export a function to create objects on top of this prototype. This function accepts a name parameter and uses Object.create() to create a new object using the prototype, adding or modifying on its basis.

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

Actual usage: it can be seen that new User has been changed to 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 subscribed to 3
    user2.subscribe(user3, mockNotifyUser2); // 2 subscribed to 3
    user3.online();  // 3 comes online, calling mockNotifyUser1 and mockNotifyUser2
    expect(mockNotifyUser1).toBeCalledWith(user3);
    expect(mockNotifyUser2).toBeCalledWith(user3);
});

Proxy Pattern#

Allows custom control over the access methods of team member objects and permits additional processing before and after updates. It is commonly used in monitoring, proxy tools, front-end frameworks, etc. JS has a built-in proxy object: Proxy(), which is also elaborated in detail in the red book.

Implementing User Status Subscription with Proxy Pattern#

Still using the previous observer pattern example, we can optimize it using the proxy pattern, letting its online function do only 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 }[]; // Array of subscribers, including users and their notification functions when online
    constructor(name: string) {
        this.name = name;
        this.status = "offline";
        this.followers = [];
    }
    subscribe(user:User, notify: Notify) {
        user.followers.push({user, notify});
    }
    online() { // The user comes online and calls its subscription functions
        this.status = "online";
        // this.followers.forEach( ({notify}) => {
        //     notify(this);
        // });
    }
}

Create a proxy for the User: ProxyUser.

Explanation of the Proxy function

target

The target object to be wrapped by Proxy (can be any type of object, including native arrays, functions, or even another proxy).

handler

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

// Create a proxy to listen for changes in its online status
export const createProxyUser = (name:string) => {
    const user = new User(name); // Normal user
    // The proxy 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#

Accessing data in a collection without exposing the data type is common in data structures with multiple data types (lists, trees, etc.), providing a universal operation interface.

Using for of to Iterate All Components#

Utilizes Symbol.iterator, which can be used with for...of loops.

Define a list queue, taking a node from the front each time. If this node has child nodes, add them all to the back. Each time next is called, it 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 child nodes, 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 through all child elements in the 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 through all elements of body, including child elements in 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 previously discussed Proxy.

Vue Component Implementing 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 can count change with clicks? This relates to the proxy of DOM operations in front-end frameworks:

Change DOM properties -> View update

Change DOM properties -> Update virtual DOM -Diff-> View update

Below is a proxy for the DOM in front-end frameworks, allowing operations before and after updates through provided hooks:

<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#

Allows multiple objects to be used together or a single object to be used independently, commonly applied in front-end components, with the classic example being the component structure in React.

React Component Structure#

Still using the counter example.

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

The Count can be rendered independently or rendered within App, the latter being a form of composition.

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

Summary and Reflections#

Below are some summaries from the teacher:

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

This lesson covered common design patterns in browsers and JavaScript, including Singleton, Observer, Prototype, Proxy, Iterator patterns, etc., and discussed the utility of design patterns. In my view, learning design patterns from actual projects is indeed a good approach.

Most of the content cited in this article comes from Teacher Wu Lining's class.

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.