《JavaScript 模式》知識點小抄本(上)

介紹

最近開始給本身每週訂個學習任務,學習結果反饋爲一篇文章的輸出,作好學習記錄。
這一週(02.25-03.03)我定的目標是《JavaScript 模式》的第七章學習一遍,學習結果的反饋就是本篇文章啦。
因爲內容實在太長,我將本文分爲兩部分:前端

本文內容中主要參考《JavaScript 模式》,其中也有些案例是來自網上資料,有備註出處啦,如形成不便,請聯繫我刪改。 git

過兩天我會把這篇文章收錄到我整理的知識庫 【Cute-JavaScript】 中,並已經同步到 【github】上面。github

1、單體模式(Singleton Pattern)

1.概念介紹

單體模式(Singleton Pattern)的思想在於保證一個特定類僅有一個實例,即無論使用這個類建立多少個新對象,都會獲得與第一次建立的對象徹底相同算法

它讓咱們能將代碼組織成一個邏輯單元,並能夠經過單一變量進行訪問。 segmentfault

單體模式有如下優勢:設計模式

  • 用來劃分命名空間,減小全局變量數量。
  • 使代碼組織的更一致,提升代碼閱讀性和維護性。
  • 只能被實例化一次。

但在JavaScript中沒有類,只有對象。當咱們建立一個新對象,它都是個新的單體,由於JavaScript中永遠不會有徹底相等的對象,除非它們是同一個對象。
所以,咱們每次使用對象字面量建立對象的時候,實際上就是在建立一個單例數組

let a1 = { name : 'leo' };
let a2 = { name : 'leo' };
a1 === a2;  // false
a1 == a2;   // false

這裏須要注意,單體模式有個條件,是該對象能被實例化,好比下面這樣就不是單體模式,由於它不能被實例化:微信

let a1 = {
    b1: 1, b2: 2,
    m1: function(){
        return this.b1;
    },
    m2: function(){
        return this.b2;
    }
}
new a1();  // Uncaught TypeError: a1 is not a constructor

下面展現一個單體模式的基本結構:閉包

let Singleton = function (name){
    this.name = name;
    this.obj = null;
}
Singleton.prototype.getName = function(){
    return this.name;
}
function getObj(name){
    return this.obj || (this.obj = new Singleton(name));
}
let g1 = getObj('leo');
let g2 = getObj('pingan');
g1 === g2;    // true
g1 == g2;     // true
g1.getName(); // 'leo'
g2.getName(); // 'leo'

從這裏能夠看出,單體模式只能實例化一次,後面再調用的話,都是使用第一次實例化的結果。app

2.應用場景

單例模式只容許實例化一次,能提升對象訪問速度而且節約內存,一般被用於下面場景:

  • 須要頻繁建立再銷燬的對象,或頻繁使用的對象:如:彈窗,文件;
  • 經常使用的工具類對象;
  • 經常使用的資源消耗大的對象;

3.實現彈框案例

這裏咱們要用單體模式,建立一個彈框,大概須要實現:元素值建立一次,使用的時候直接調用。
所以咱們這麼作:

let create = (() => {
    let div;
    return () => {
        if(!div){
            div = document.createElement('div');
            div.innderHTML = '我是leo建立的彈框';
            div.style.display = 'none';
            div.setAttribute("id", "leo");
            document.body.appendChild(div);
        }
        return div;
    }
})();
// 觸發事件
document.getElementById('otherBtn').onclick = () => {
    let first = create();
    first.style.display = 'block';
}

4.使用new操做符

因爲JavaScript中沒有類,但JavaScript有new語法來用構造函數建立對象,並可使用這種方法實現單體模式。
當使用同一個構造函數以new操做符建立多個對象,得到的是指向徹底相同的對象的新指針。

一般咱們使用new操做符建立單體模式的三種選擇,讓構造函數總返回最初的對象:

  • 使用全局對象來存儲該實例(不推薦,容易全局污染)。
  • 使用靜態屬性存儲該實例,沒法保證該靜態屬性的私有性。
function Leo(name){
    if(typeof Leo.obj === 'object'){
        return Leo.obj;
    }
    this.name = name;
    Leo.obj = this;
    return this;
}
let a1 = new Leo('leo');
let a2 = new Leo('pingan');
a1 === a2 ; // true
a1 ==  a2 ; // true

惟一的缺點就是obj屬性是公開的,容易被修改。

  • 使用閉包將該實例包裹,保證明例是私有性並不會被外界修改。

咱們這經過重寫上面的方法,加入閉包:

function Leo(name){
    let obj;
    this.name = name;
    obj = this;       // 1.存儲第一次建立的對象
    Leo = function(){ // 2.修改原來的構造函數
        return obj;
    }
}
let a1 = new Leo('leo');
let a2 = new Leo('pingan');
a1 === a2 ; // true
a1 ==  a2 ; // true

當咱們第一次調用構造函數,像往常同樣返回this,然後面再調用的話,都將重寫構造函數,並訪問私有變量obj並返回。

2、工廠模式(Factory Pattern)

1.概念介紹

工廠模式的目的在於建立對象,實現下列目標:

  • 可重複執行,來建立類似對象;
  • 當編譯時位置具體類型(類)時,爲調用者提供一種建立對象的接口;

經過工廠方法(或類)建立的對象,都繼承父對象,下面一個簡單工廠方法理解:

function Person(name, age, sex){
    let p = {}; // 或 let p = new Object(); 建立一個初始對象
    p.name = name;
    p.age = age;
    p.sex = sex;
    p.ask = function(){
        return 'my name is' + this.name;
    }
    return p;
}
let leo = new Person('leo', 18, 'boy');
let pingan = new Person('pingan', 18, 'boy');
console.log(leo.name, leo.age, leo.sex);          // 'leo', 18, 'boy'
console.log(pingan.name, pingan.age, pingan.sex); // 'pingan', 18, 'boy'

經過調用Person構造函數,咱們能夠像工廠那樣,生產出無數個包含三個屬性和一個方法的對象。
能夠看出,工廠模式能夠解決建立多個相似對象的問題。

2.優缺點

2.1優勢

  • 一個調用者想建立一個對象,只要知道其名稱就能夠了。
  • 擴展性高,若是想增長一個產品,只要擴展一個工廠類就能夠。
  • 屏蔽產品的具體實現,調用者只關心產品的接口。

2.2缺點

每次增長一個產品時,都須要增長一個具體類和對象實現工廠,使得系統中類的個數成倍增長,在必定程度上增長了系統的複雜度,同時也增長了系統具體類的依賴。這並非什麼好事。

3.實現複雜工廠模式

在複雜工廠模式中,咱們將其成員對象的實列化推遲到子類中,子類能夠重寫父類接口方法以便建立的時候指定本身的對象類型。
父類相似一個公共函數,只處理建立過程當中的問題,而且這些處理將被子類繼承,而後在子類實現專門功能。

好比這裏咱們須要實現這麼一個實例:

  • 須要一個公共父函數CarMaker
  • 父函數CarMaker有個factor靜態方法,用於建立car對象;
  • 定義三個靜態屬性,值爲三個函數,用於繼承父函數CarMaker

而後咱們但願這麼使用這個函數:

let c1 = CarMaker.factory('Car1');
let c2 = CarMaker.factory('Car2');
let c3 = CarMaker.factory('Car3');
c1.drirve();  // '個人編號是6'
c2.drirve();  // '個人編號是3'
c3.drirve();  // '個人編號是12'

能夠看出,調用時接收以字符串形式指定類型,並返回請求類型的對象,而且這樣使用是不須要用new操做符。

下面看代碼實現:

// 建立父構造函數
function CarMaker(){};
CarMaker.prototype.drive = function(){
    return `個人編號是${this.id}`;
}
// 添加靜態工廠方法
CarMaker.factory = function (type){
    let types = type, newcar;
    // 若構造函數不存在 則發生錯誤
    if(typeof CarMaker[types] !== 'function'){
        throw{ name: 'Error', message: `${types}不存在`};
    }
    // 若構造函數存在,則讓原型繼承父類,但僅繼承一次
    if(CarMaker[types].prototype.drive !== 'function'){
        CarMaker[types].prototype = new CarMaker();
    }
    // 建立新實例,並返回
    newcar = new CarMaker[types]();
    return newcar;
}
// 調用
CarMaker.c1 = function(){
    this.id = 6;
}
CarMaker.c2 = function(){
    this.id = 3;
}
CarMaker.c3 = function(){
    this.id = 12;
}

定義完成後,咱們再執行前面的代碼:

let c1 = CarMaker.factory('Car1');
let c2 = CarMaker.factory('Car2');
let c3 = CarMaker.factory('Car3');
c1.drirve();  // '個人編號是6'
c2.drirve();  // '個人編號是3'
c3.drirve();  // '個人編號是12'

就能正常打印結果了。

實現該工廠模式並不困難,主要是要找到可以穿件所需類型對象的構造函數。
這裏使用簡單的映射來建立該對象的構造函數。

4.內置對象工廠

內置的對象工廠,就像全局的Object()構造函數,也是工廠模式的行爲,根據輸入類型建立不一樣對象。
如傳入一個原始數字,返回一個Number()構造函數建立一個對象,傳入一個字符串或布爾值也成立。
對於傳入任何其餘值,包括無輸入的值,都會建立一個常規的對象。

不管是否使用new操做符,均可以調用Object(),咱們這麼測試:

let a = new Object(), b = new Object(1),
    c = Object('1'),  d = Object(true);

a.constructor === Object;  // true 
b.constructor === Number;  // true 
c.constructor === String;  // true 
d.constructor === Boolean; // true

事實上,Object()用途不大,這裏列出來是由於它是咱們比較常見的工廠模式。

3、迭代器模式(Iterator Pattern)

1.概念介紹

迭代器模式(Iterator Pattern)是提供一種方法,順序訪問一個聚合對象中每一個元素,而且不暴露該對象內部。

這種模式屬於行爲型模式,有如下幾個特色:

  • 訪問一個聚合對象的內容,而無需暴露它的內部表示。
  • 提供統一接口來遍歷不一樣結構的數據集合。
  • 遍歷的同事更改迭代器所在的集合結構可能會致使問題。

在迭代器模式中,一般包含有一個包含某種數據集合的對象,須要提供一種簡單的方法來訪問每一個元素。
這裏對象須要提供一個next()方法,每次調用都必須返回下一個連續的元素。

這裏假設建立一個對象leo,咱們經過調用它的next()方法訪問下一個連續的元素:

let obj;
while(obj = leo.next()){
    // do something
    console.log(obj);
}

另外迭代器模式中,聚合對象還會提供一個更爲漸變的hasNext()方法,來檢查是否已經到達數據末尾,咱們這麼修改前面的代碼:

while(leo.hasNext()){
    // do something
    console.log(obj);
}

2.優缺點和應用場景

2.1優勢

  • 它簡化了聚合類,並支持以不一樣的方式遍歷一個聚合對象。
  • 在同一個聚合上能夠有多個遍歷。
  • 在迭代器模式中,增長新的聚合類和迭代器類都很方便,無須修改原有代碼。

2.2缺點

因爲迭代器模式將存儲數據和遍歷數據的職責分離,增長新的聚合類須要對應增長新的迭代器類,類的個數成對增長,這在必定程度上增長了系統的複雜性。

2.3應用場景

  • 訪問一個聚合對象的內容而無須暴露它的內部表示。
  • 須要爲聚合對象提供多種遍歷方式。
  • 爲遍歷不一樣的聚合結構提供一個統一的接口。

3.簡單案例

根據上面的介紹,咱們這裏實現一個簡單案例,將設咱們數據只是普通數組,而後每次檢索,返回的是間隔一個的數組元素(即不是連續返回):

let leo = (function(){
    let index = 0, data = [1, 2, 3, 4, 5],
        len = data.length;
    return {
        next: function(){
            let obj;
            if(!this.hasNext()){
                return null;
            };
            obj = data[index];
            index = index + 2;
            return obj;
        },
        hasNext: function(){
            return index < len;
        }
    }
})()

而後咱們還要給它提供更簡單的訪問方式和屢次迭代數據的能力,咱們須要添加下面兩個方法:

  • rewind() 重置指針到初始位置;
  • current() 返回當前元素,由於當指針步前進時沒法使用next()操做;

代碼變成這樣:

let leo = (function(){
    //.. 
    return {
         // .. 
         rewind: function(){
             index = 0;
         },
         current: function(){
             return data[index];
         }
    }
})();

這樣這個案例就完整了,接下來咱們來測試:

// 讀取記錄
while(leo.hasNext()){
    console.log(leo.next());
};  // 打印 1 3 5
// 回退
leo.rewind();
// 獲取當前
console.log(leo.current()); // 回到初始位置,打印1

4.應用場景

迭代器模式一般用於:對於集合內部結果經常變化各異,咱們不想暴露其內部結構的話,但又響讓客戶代碼透明底訪問其中的元素,這種狀況下咱們可使用迭代器模式。

簡單理解:遍歷一個聚合對象。

  • jQuery應用例子:

jQuery中的$.each()方法,可讓咱們傳入一個方法,實現對全部項的迭代操做:

$.each([1,2,3,4,5],function(index, value){
    console.log(`${index}: ${value}`)
})
  • 使用迭代器模式實現each()方法
let myEach = function(arr, callback){
    for(var i = 0; i< arr.length; i++){
        callback(i, arr[i]);
    }
}

4.小結

迭代器模式是一種相對簡單的模式,目前絕大多數語言都內置了迭代器。並且迭代器模式也是很是經常使用,有時候不經意就是用了。

4、裝飾者模式(Decorator Pattern)

1.概念介紹

裝飾者模式(Decorator Pattern):在不改變原類和繼承狀況下,動態添加功能到對象中,經過包裝一個對象實現一個新的具備原對象相同接口的新對象。

裝飾者模式有如下特色:

  1. 添加功能時不改變原對象結構
  2. 裝飾對象和原對象提供的接口相同,方便按照源對象的接口來使用裝飾對象。
  3. 裝飾對象中包含原對象的引用。即裝飾對象是真正的原對象包裝後的對象。

實際上,裝飾着模式的一個比較方便的特徵在於其預期行爲的可定製和可配置特性。從只有基本功能的普通對象開始,不斷加強對象的一些功能,並按照順序進行裝飾。

2.優缺點和應用場景

2.1優勢

  • 裝飾類和被裝飾類能夠獨立發展,不會相互耦合,裝飾模式是繼承的一個替代模式,裝飾模式能夠動態擴展一個實現類的功能。

2.2缺點

  • 多層裝飾比較複雜。

2.3應用場景

  • 擴展一個類的功能。
  • 動態增長功能,動態撤銷。

3.基本案例

咱們這裏實現一個基本對象sale,能夠經過sale對象獲取不一樣項目的價格,並經過調用sale.getPrice()方法返回對應價格。而且在不一樣狀況下,用額外的功能來裝飾它,會獲得不一樣狀況下的價格。

3.1建立對象

這裏咱們假設客戶須要支付國家稅和省級稅。按照裝飾者模式,咱們就須要使用國家稅和省級稅兩個裝飾者來裝飾這個sale對象,而後在對使用價格格式化功能的裝飾者裝飾。實際看起來是這樣:

let sale = new Sale(100);
sale = sale.decorate('country');
sale = sale.decorate('privince');
sale = sale.decorate('money');
sale.getPrice();

使用裝飾者模式後,每一個裝飾都很是靈活,主要根據其裝飾者順序,因而若是客戶不須要上繳國家稅,代碼就能夠這麼實現:

let sale = new Sale(100);
sale = sale.decorate('privince');
sale = sale.decorate('money');
sale.getPrice();

3.2實現對象

接下來咱們須要考慮的是如何實現Sale對象了。

實現裝飾者模式的其中一個方法是使得每一個裝飾者成爲一個對象,而且該對象包含了應該被重載的方法。每一個裝飾者實際上繼承了目前已經被前一個裝飾者進行裝飾後的對象,每一個裝飾方法在uber(繼承的對象)上調用一樣的方法並獲取值,此外還繼續執行一些操做。

uber關鍵字相似Java的 super,它可讓某個方法調用父類的方法, uber屬性指向父類原型。

即:當咱們調用sale.getPrice()方法時,會調用money裝飾者的方法,而後每一個裝飾方法都會先調用父對象的方法,所以一直往上調用,直到開始的Sale構造函數實現的未被裝飾的getPrice()方法。理解以下圖:

裝飾者模式1

咱們這裏能夠先實現構造函數Sale()和原型方法getPrice()

function Sale (price){
    this.price = price || 100;
}
Sale.prototype.getPrice = function (){
    return this.price;
}

而且裝飾者對象都將以構造函數的屬性來實現:

Sale.decorators = {};

接下來實現country這個裝飾者並實現它的getPrice(),改方法首先從父對象的方法獲取值再作修改:

Sale.decorators.country = {
    getPrice: function(){
        let price = this.uber.getPrice(); // 獲取父對象的值
        price += price * 5 / 100;
        return price;
    }
}

按照相同方法,實現其餘裝飾者:

Sale.decorators.privince = {
    getPrice: function(){
        let price = this.uber.getPrice();
        price += price * 7 / 100;
        return price;
    }
}
Sale.decorators.money = {
    getPrice: function(){
        return "¥" + this.uber.getPrice().toFixed(2);
    }
}

最後咱們還須要實現前面的decorate()方法,它將咱們全部裝飾者拼接一塊兒,而且作了下面的事情:
建立了個新對象newobj,繼承目前咱們所擁有的對象(Sale),不管是原始對象仍是最後裝飾後的對象,這裏就是對象this,並設置newobjuber屬性,便於子對象訪問父對象,而後將全部裝飾者的額外屬性複製到newobj中,返回newobj,即成爲更新的sale對象:

Sale.prototype.decorate = function(decorator){
    let F = function(){}, newobj,
        overrides = this.constructor.decorators[decorator];
    F.prototype = this;
    newobj = new F();
    newobj.user = F.prototype;
    for(let k in overrides){
        if(overrides.hasOwnProperty(k)){
            newobj[k] = overrides[k];
        }
    }
    return newobj;
}

4.改造基本案例

這裏咱們使用列表實現相同功能,這個方法利用JavaScript語言的動態性質,而且不須要使用繼承,也不須要讓每一個裝飾方法調用鏈中前面的方法,能夠簡單的將前面方法的結果做爲參數傳遞給下一個方法。

這樣實現也有個好處,支持反裝飾或撤銷裝飾,咱們仍是實現如下功能:

let sale = new Sale(100);
sale = sale.decorate('country');
sale = sale.decorate('privince');
sale = sale.decorate('money');
sale.getPrice();

如今的Sale()構造函數中多了個裝飾者列表的屬性:

function Sale(price){
    this.price = (price > 0) || 100;
    this.decorators_list = [];
}

而後仍是須要實現Sale.decorators,這裏的getPrice()將變得更簡單,也沒有去調用父對象的getPrice(),而是將結果做爲參數傳遞:

Sale.decorators = {};
Sale.decorators.country = {
    getPrice: function(price){
        return price + price * 5 / 100;
    }
}
Sale.decorators.privince = {
    getPrice: function(price){
        return price + price * 7 / 100;
    }
}
Sale.decorators.money = {
    getPrice: function(price){
        return "¥" + this.uber.getPrice().toFixed(2);
    }
}

而這時候父對象的decorate()getPrice()變得複雜,decorate()用於追加裝飾者列表,getPrice()須要完成包括遍歷當前添加的裝飾者一級調用每一個裝飾者的getPrice()方法、傳遞從前一個方法得到的結果:

Sale.prototype.decorate = function(decorators){
    this.decorators_list.push(decorators);
}

Sale.propotype.getPrice = function(){
    let price = this.price, name;
    for(let i = 0 ;i< this.decorators_list.length; i++){
        name = this.decorators_list[i];
        price = Sale.decorators[name].getPrice(price);
    }
    return price;
}

5.對比兩個方法

很顯然,第二種列表實現方法會更簡單,不用設計繼承,而且裝飾方法也簡單。
案例中getPrice()是惟一能夠裝飾的方法,若是想實現更多能夠被裝飾的方法,咱們能夠抽一個方法,來將每一個額外的裝飾方法重複遍歷裝飾者列表中的這塊代碼,經過它來接收方法並使其成爲「可裝飾」的方法。這樣實現,saledecorators_list屬性會成爲一個對象,且該對象每一個屬性都是以裝飾者對象數組中的方法和值命名。

5、策略模式(Strategy Pattern)

1.概念介紹

策略模式(Strategy Pattern):封裝一系列算法,支持咱們在運行時,使用相同接口,選擇不一樣算法。它的目的是爲了將算法的使用與算法的實現分離開來

策略模式一般會有兩部分組成,一部分是策略類,它負責實現通用的算法,另外一部分是環境類,它用戶接收客戶端請求並委託給策略類。

2.優缺點

2.1優勢

  • 有效地避免多重條件選擇語句;
  • 支持開閉原則,將算法獨立封裝,使得更加便於切換、理解和擴展;
  • 更加便於代碼複用;

2.2缺點

  • 策略類會增多;
  • 全部策略類都須要對外暴露;

3.基本案例

咱們能夠很簡單的將策略和算法直接作映射:

let add = {
    "add3" : (num) => num + 3,
    "add5" : (num) => num + 5,
    "add10": (num) => num + 10,
}
let demo = (type, num) => add[type](num);
console.log(demo('add3', 10));  // 13
console.log(demo('add10', 12)); // 22

而後咱們再把每一個策略的算法抽出來:

let fun3  = (num) => num + 3;
let fun5  = (num) => num + 5;
let fun10 = (num) => num + 10;
let add = {
    "add3" : (num) => fun3(num),
    "add5" : (num) => fun5(num),
    "add10": (num) => fun10(num),
}
let demo = (type, num) => add[type](num);
console.log(demo('add3', 10));  // 13
console.log(demo('add10', 12)); // 22

4.表單驗證案例

咱們須要使用策略模式,實現一個處理表單驗證的方法,不管表單的具體類型是什麼都會調用驗證方法。咱們須要讓驗證器能選擇最佳的策略來處理任務,並將具體的驗證數據委託給適當算法。

咱們假設須要驗證下面的表單數據的有效性:

let data = {
    name    : 'pingan',
    age     : 'unknown',
    nickname: 'leo',
}

這裏須要先配置驗證器,對錶單數據中不一樣的數據使用不一樣的算法:

validator.config = {
    name    : 'isNonEmpty',
    age     : 'isNumber',
    nickname: 'isAlphaNum',
}

而且咱們須要將驗證的錯誤信息打印到控制檯:

validator.validate(data);
if(validator.hasErrors()){
    console.log(validator.msg.join('\n'));
}

接下來咱們纔要實現validator中具體的驗證算法,他們都有一個相同接口validator.types,提供validate()方法和instructions幫助信息:

// 非空值檢查
validator.types.isNonEmpty = {
    validate: function(value){
        return value !== '';
    }
    instructions: '該值不能爲空'
}

// 數值類型檢查
validator.types.isNumber = {
    validate: function(value){
        return !isNaN(value);
    }
    instructions: '該值只能是數字'
}

// 檢查是否只包含數字和字母
validator.types.isAlphaNum = {
    validate: function(value){
        return !/[^a-z0-9]/i.test(value);
    }
    instructions: '該值只能包含數字和字母,且不包含特殊字符'
}

最後就是要實現最核心的validator對象:

let validator = {
    types: {}, // 全部可用的檢查
    msg:[],    // 當前驗證的錯誤信息
    config:{}, // 驗證配置
    validate: function(data){ // 接口方法
        let type, checker, result;
        this.msg = []; // 清空錯誤信息
        for(let k in data){
            if(data.hasOwnProperty(k)){
                type = this.config[k];
                checker = this.types[type];
                if(!type) continue;  // 不存在類型 則 不須要驗證
                if(!checker){
                    throw {
                        name: '驗證失敗',
                        msg: `不能驗證類型:${type}`
                    }
                }
                result = checker.validate(data[k]);
                if(!result){
                    this.msg.push(`無效的值:${k},${checker.instructions}`);
                }
            }
        }
        return this.hasErrors();
    }
    hasErrors: function(){
        return this.msg.length != 0;
    }
}

總結這個案例,咱們能夠看出validator對象是通用的,須要加強validator對象的方法只需添加更多的類型檢查,後續針對每一個新的用例,只需配置驗證器和運行validator()方法就能夠。

5.小結

平常開發的時候,仍是須要根據實際狀況來選擇設計模式,而不能爲了設計模式而去設計模式。經過上面的學習,咱們使用策略模式來避免多重條件判斷,而且經過開閉原則來封裝方法。咱們應該多在開發中,逐漸積累本身的開發工具庫,便於之後使用。

參考資料

  1. 《JavaScript Patterns》
Author 王平安
E-mail pingan8787@qq.com
博 客 www.pingan8787.com
微 信 pingan8787
每日文章推薦 https://github.com/pingan8787...
JS小冊 js.pingan8787.com
微信公衆號 前端自習課

前端自習課

相關文章
相關標籤/搜索