banner
cos

cos

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

青訓キャンプ |「フロントエンドデザインパターンの応用」ノート

デザインパターンとは#

デザインパターンはソフトウェア設計における一般的な問題の解決策モデルであり、歴史的な経験の要約であり、特定の言語に依存しません。

デザインパターンは大きく分けて 23 種類のデザインパターンに分類されます。

  • 創造的 —— 効率的かつ柔軟にオブジェクトを作成する方法
  • 構造的 —— オブジェクトを柔軟に組み立てて大きな構造を作る方法
  • 行動的 —— オブジェクト間の効率的な通信と責任の分担を担当します。

ブラウザにおけるデザインパターン#

シングルトンパターン#

シングルトンパターン —— グローバルにアクセス可能なオブジェクトが存在し、どこからでもアクセスおよび変更が行われると、このオブジェクトに反映されます。

最も一般的なのはブラウザの window オブジェクトであり、ブラウザ操作のラッピングを提供し、キャッシュやグローバルステート管理などによく使用されます。

シングルトンパターンによるリクエストキャッシュの実装#

シングルトンパターンを使用してリクエストキャッシュを実装します:同じ url リクエストで、2 回目のリクエストを送信する際に以前の値を再利用できることを望みます。

まず、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);
});
// 先に1回リクエストを行い、2回目のリクエストの時間をテストします。
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 クラスを作成し、コンストラクタで初期状態をオフラインに設定し、フォロワーオブジェクトの配列を持ち、そのユーザーが購読しているすべての {ユーザー、呼び出し関数} を含みます。ユーザーがオンラインになるたびに、そのフォロワーを遍歴して通知します。

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 の一般的な言語特性であるプロトタイプチェーンを考えると、プロトタイプパターンは実際には既存のオブジェクトをコピーして新しいオブジェクトを作成することを指します。これはオブジェクトが非常に大きい場合に比較的良いパフォーマンスを発揮します(直接作成するのに比べて)。JavaScript におけるオブジェクトの作成によく使用されます。

プロトタイプパターンによるオンライン購読中のユーザーの作成#

まず、プロトタイプを作成します。このプロトタイプは、以前のものと比較してコンストラクタを定義していないことがわかります。

// プロトタイプパターン、もちろんプロトタイプが必要です
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 関数が 1 つのことだけを行うようにします:状態をオンラインに変更します。

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); // 通常のユーザー
    // プロキシオブジェクト
    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 でよく使われるデザインパターン、シングルトンパターン、オブザーバーパターン、プロトタイプパターン、プロキシパターン、イテレーターパターンなどについて説明し、デザインパターンが実際にどのように役立つかについても触れました。私の見解では、実際のプロジェクトからデザインパターンを学ぶことは確かに良い方法です。

本文で引用されたほとんどの内容は、吴立宁先生の授業に基づいています。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。