介紹
最近開始給自己每週訂個學習任務,學習結果反饋為一篇文章的輸出,做好學習記錄。
這一週(02.25-03.03)我定的目標是《JavaScript 模式》的第七章學習一遍,學習結果的反饋就是本篇文章啦。
由於內容實在太長,我將本文分為兩部分:
- 《JavaScript 模式》知識點整理(上)
- 《JavaScript 模式》知識點整理(下)
本文內容中主要參考《JavaScript 模式》,其中也有些案例是來自網上資料,有備註出處啦,如造成不便,請聯絡我刪改。
過兩天我會把這篇文章收錄到我整理的知識庫 【Cute-JavaScript】 中,並已經同步到 【github】上面。
一、單體模式(Singleton Pattern)
1.概念介紹
單體模式(Singleton Pattern)的思想在於保證一個特定類僅有一個例項,即不管使用這個類建立多少個新物件,都會得到與第一次建立的物件完全相同。
它讓我們能將程式碼組織成一個邏輯單元,並可以通過單一變數進行訪問。
單體模式有以下優點:
- 用來劃分名稱空間,減少全域性變數數量。
- 使程式碼組織的更一致,提高程式碼閱讀性和維護性。
- 只能被例項化一次。
但在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'
複製程式碼
從這裡可以看出,單體模式只能例項化一次,後面再呼叫的話,都是使用第一次例項化的結果。
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
並返回。
二、工廠模式(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()
用途不大,這裡列出來是因為它是我們比較常見的工廠模式。
三、迭代器模式(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.小結
迭代器模式是一種相對簡單的模式,目前絕大多數語言都內建了迭代器。而且迭代器模式也是非常常用,有時候不經意就是用了。
四、裝飾者模式(Decorator Pattern)
1.概念介紹
裝飾者模式(Decorator Pattern):在不改變原類和繼承情況下,動態新增功能到物件中,通過包裝一個物件實現一個新的具有原物件相同介面的新物件。
裝飾者模式有以下特點:
- 新增功能時不改變原物件結構。
- 裝飾物件和原物件提供的介面相同,方便按照源物件的介面來使用裝飾物件。
- 裝飾物件中包含原物件的引用。即裝飾物件是真正的原物件包裝後的物件。
實際上,裝飾著模式的一個比較方便的特徵在於其預期行為的可定製和可配置特性。從只有基本功能的普通物件開始,不斷增強物件的一些功能,並按照順序進行裝飾。
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()
方法。理解如下圖:
我們這裡可以先實現建構函式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
,並設定newobj
的uber
屬性,便於子物件訪問父物件,然後將所有裝飾者的額外屬性複製到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()
是唯一可以裝飾的方法,如果想實現更多可以被裝飾的方法,我們可以抽一個方法,來將每個額外的裝飾方法重複遍歷裝飾者列表中的這塊程式碼,通過它來接收方法並使其成為“可裝飾”的方法。這樣實現,sale
的decorators_list
屬性會成為一個物件,且該物件每個屬性都是以裝飾者物件陣列中的方法和值命名。
五、策略模式(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.小結
日常開發的時候,還是需要根據實際情況來選擇設計模式,而不能為了設計模式而去設計模式。通過上面的學習,我們使用策略模式來避免多重條件判斷,並且通過開閉原則來封裝方法。我們應該多在開發中,逐漸積累自己的開發工具庫,便於以後使用。
參考資料
- 《JavaScript Patterns》
Author | 王平安 |
---|---|
pingan8787@qq.com | |
博 客 | www.pingan8787.com |
微 信 | pingan8787 |
每日文章推薦 | https://github.com/pingan8787/Leo_Reading/issues |
JS小冊 | js.pingan8787.com |
微信公眾號 | 前端自習課 |