banner
cos

cos

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

青訓營 |「前端設計模式應用」筆記

什麼是設計模式#

設計模式是軟體設計中常見問題的解決方案模型,是歷史經驗的總結,與特定語言無關

設計模式大致分為 23 種設計模式

  • 創建型 —— 如何高效靈活的創建一個對象
  • 結構型 —— 如何靈活的將對象組裝成較大的結構
  • 行為型 —— 負責對象間的高效通信和職責劃分

瀏覽器中的設計模式#

單例模式#

單例模式 —— 存在一個全局訪問對象,在任意地方訪問和修改都會反映在這個對象上

最常用的其實就是瀏覽器中的 window 對象,提供了對瀏覽器的操作的封裝,常用於緩存與全局狀態管理等

單例模式實現請求緩存#

用單例模式實現請求緩存:相同的 url 請求,希望第二次發送請求的時候可以重用之前的一些值

首先創建 Request 類,該類包含一個創建單例對象的靜態方法 getinstance,然後真正請求的操作為 request 方法,向 url 發送請求,若緩存中存在該 url 則直接返回,反之則緩存到該單例對象中。可以看到如下是上節課講過的語法~

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();  // 之前還未有過請求,初始化該單例
        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;
    }
}

實際中使用如下:利用 getInstance 靜態方法創建該單例對象,並測試其執行時間進行對比。

ps: 這裡的測試是使用Jest 進行的,其中用到了部分expect的 api,可以通過文檔了解其用途

// 不預先進行請求,測試其時間。
test('should response more than 500ms with class', async() => {
    const request = Request.getinstance();  //獲取/創建一個單例對象(若之前未創建過則創建)
    const startTime = Date.now();
    await request.request('/user/1');
    const endTime = Date.now();

    const costTime = endTime-startTime;
    expect(costTime).toBeGreaterThanOrEqual(500);
});
// 先進行一次請求,在測試第二次請求的時間
test('should response quickly second time with class', async() => {
    const request1 = Request.getinstance();
    await request1.request('/user/1');

    const startTime = Date.now();   // 測試這一部分的時間
    const request2 = Request.getinstance();
    await request2.request('/user/1');
    const endTime = Date.now();		//

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

而在 js 中,我們也可以不用 class 寫,這是因為傳統的語言中無法 export 出來一個獨立的方法等,只能 export 出來一個類

// 不用class?可以更簡潔
import {api} from './utils';
const cache: Record<string,string> = {};
export const request = async (url:string) => {
    if(cache[url]) {    // 與class中一致
        return cache[url];
    }
    const response = await api(url);

    cache[url] = response;
    return response;
};
// 使用,可以看出來該方法也符合單例模式,但更加簡潔。
test('should response quickly second time', async() => {
    await request('/user/1');
    const startTime = Date.now();   // 測試這一部分的時間
    await request('/user/1');
    const endTime = Date.now();

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

發布訂閱模式(觀察者模式)#

應用非常廣泛的一種模式,在被訂閱對象發生變化時通知訂閱者,常見場景很多,從系統架構之間的解耦到業務中的一些實現模式、郵件訂閱等等。類似於添加事件

發布訂閱模式實現用戶上線訂閱#

舉個實際應用的例子:通過該模式,我們可以實現用戶的相互訂閱,在該用戶上線時調用相應的通知函數。

如圖創建了一個 User 類,構造器中初始狀態置為離線,其擁有一個 followers 對象數組,包括了該用戶訂閱的所有 {用戶,調用函數},每次在該用戶上線時,遍歷其 followers 進行通知

type Notify = (user: User) => void;
export class User {
    name: string;
    status: "offline" | "online";// 狀態 離線/在線
    followers: { user:User; notify: Notify }[]; // 訂閱他人的數組,包括用戶及其上線時的通知函數
    constructor(name: string) {
        this.name = name;
        this.status = "offline";
        this.followers = [];
    }
    subscribe(user:User, notify: Notify) {
        user.followers.push({user, notify});
    }
    online() { // 該用戶上線 調用其訂閱函數
        this.status = "online";
        this.followers.forEach( ({notify}) => {
            notify(this);
        });
    }
}

測試函數:還是用 jest,創建假的訂閱函數進行測試(

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();   // 通知user1的函數
   const mockNotifyUser2 = jest.fn();   // 通知user2的函數
   user1.subscribe(user3, mockNotifyUser1); // 1訂閱了3
   user2.subscribe(user3, mockNotifyUser2); // 2訂閱了3
   user3.online();  // 3上線,調用mockNotifyUser1和mockNotifyUser2
   expect(mockNotifyUser1).toBeCalledWith(user3);
   expect(mockNotifyUser2).toBeCalledWith(user3);
});

JavaScript 中的設計模式#

原型模式#

可以想到 javascript 中的常見語言特性:原型鏈,原型模式指的其實就是複製一個已有的對象來創建新的對象,這在對象十分龐大的時候會有比較好的性能(相比起直接創建)。常用於 js 中對象的創建

原型模式創建上線訂閱中的用戶#

首先,創建一個原型,可以看到這個原型相比起之前的來說沒有定義構造器。

// 原型模式,當然要有原型啦
const baseUser:User = { 
    name: "",
    status: "offline",
    followers: [],
    subscribe(user, notify) {
        user.followers.push({user, notify});
    },
    online() { // 該用戶上線 調用其訂閱函數
        this.status = "online";
        this.followers.forEach( ({notify}) => {
            notify(this);
        });
    }
}

導出在該原型之上創建對象的函數,該函數接受一個 name 參數,利用Object.create() 使用原型來創建一個新對象,並在其基礎上進行增加或修改

// 然後導出在該原型之上創建對象的函數
export const createUser = (name:string) => {
    const user:User = Object.create(baseUser);
    user.name = name;
    user.followers = [];
    return user;
};

實際使用:可以看到將 new User 變成了 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();   // 通知user1的函數
    const mockNotifyUser2 = jest.fn();   // 通知user2的函數
    user1.subscribe(user3, mockNotifyUser1); // 1訂閱了3
    user2.subscribe(user3, mockNotifyUser2); // 2訂閱了3
    user3.online();  // 3上線,調用mockNotifyUser1和mockNotifyUser2
    expect(mockNotifyUser1).toBeCalledWith(user3);
    expect(mockNotifyUser2).toBeCalledWith(user3);
});

代理模式#

可自定義控制隊員對象的訪問方式,並且允許在更新前後做一些額外處理,常用於監控、代理工具、前端框架等等。JS 中有自帶的代理對象:Proxy() ,在紅寶書中代理那章也有詳細闡述。

代理模式實現用戶狀態訂閱#

還是上述觀察者模式的例子,可以使用代理模式對其進行優化,讓他的 online 函數只做一件事:更改狀態為上線。

type Notify = (user: User) => void;
export class User {
    name: string;
    status: "offline" | "online";// 狀態 離線/在線
    followers: { user:User; notify: Notify }[]; // 訂閱他人的數組,包括用戶及其上線時的通知函數
    constructor(name: string) {
        this.name = name;
        this.status = "offline";
        this.followers = [];
    }
    subscribe(user:User, notify: Notify) {
        user.followers.push({user, notify});
    }
    online() { // 該用戶上線 調用其訂閱函數
        this.status = "online";
        // this.followers.forEach( ({notify}) => {
        //     notify(this);
        // });
    }
}

創建 User 的一個代理:ProxyUser

Proxy 函數說明

target

要使用 Proxy 包裝的目標對象(可以是任何類型的對象,包括原生數組,函數,甚至另一個代理)。

handler

一個通常以函數作為屬性的對象,各屬性中的函數分別定義了在執行各種操作時代理 p 的行為。

// 創建代理,監聽其上線狀態的變化
export const createProxyUser = (name:string) => {
    const user = new User(name); //正常的user
    // 代理的對象
    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;
}

迭代器模式#

在不暴露數據類型的情況下訪問集合中的數據,常用於數據結構中擁有多種數據類型(列表、

樹等),提供通用的操作接口。

用 for of 迭代所有組件#

用到了 Symbol.iterator 該迭代器可以被 for...of 循環使用。

定義一個 list 隊列,每次從隊首取個節點出來,如果這個節點有孩子結點將其全部添加到隊尾,每次調用 next 都返回一個結點~詳見代碼

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())) { // 每次從隊首取個節點出來,如果有孩子結點將其添加到隊尾
                    node.children.length > 0 && list.push(...node.children);
                    return { value: node, done: false };
                }
                return { value:null, done:true };
            },
        };
    }
}

使用場景如下:通過 for of 迭代 body 中的所有子元素

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) {	// 迭代body的所有元素,需要包含main中的子元素
        if(element) {
            expectTags.push(element.tag);
        }
    }
    
    expect(expectTags.length).toBe(5);
});

前端框架中的設計模式(React、Vue...)#

代理模式#

與之前講的 Proxy 不太一樣

Vue 組件實現計數器#

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

上述代碼,為什麼 count 能隨點擊而變化?這就要說到前端框架中對 DOM 操作的代理了:

更改 DOM 屬性 -> 視圖更新

更改 DOM 屬性 -> 更新虛擬 DOM -Diff-> 視圖更新

如下就是前端框架對 DOM 的一個代理,通過其提供的鉤子可以在更新前後進行操作:

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

组合模式#

可以多個對象組合使用,也可以單個對象獨立使用,常應用於前端組件,最經典的就是 React 的組件結構:

React 組件結構#

還是計數器的例子~

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

該 Count,既可以獨立渲染,也可以渲染在 App 中,後者就是一種組合

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

總結感想#

下面是老師的一些總結:

設計模式不是銀彈,總結出抽象的模式聽起來比較簡單,但是想要將抽象的模式套用到實際的場景中卻非常困難,現代編程語言的多編程範式帶來了更多的可能性,我們要在真正優秀的開源項目中學習設計模式並不斷實踐

這節課講了瀏覽器和 js 中常用的設計模式,包括單例模式、觀察者模式、原型模式、代理模式、迭代器模式等,還講了設計模式究竟有什麼用~在我看來,從實際的項目中學習設計模式確實是一種比較好的方法。

本文引用的大部分內容來自吳立寧老師的課

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。