使用ECMAScript 6 模塊封裝代碼

JavaScript 用“共享一切”的方法加載代碼,這是該語言中最容易出錯且最容易讓人感到困惑的地方。其他語言使用諸如包這樣的概念來定義代碼作用域,但在 ECMAScript 6 以前,在應用程序的每一個 JavaScript 中定義的一切都共享一個全局作用域。隨着 Web 應用程序變得更加複雜,JavaScript 代碼的使用量也開始增長,這樣會引起問題,如命名衝突和安全問題。ECMAScript 6 的一個目標就是解決作用域問題,也爲了使 JavaScript 應用程序顯得有序,於是引進了模塊。

什麼是模塊

模塊是自動運行在嚴格模式下並且沒有辦法退出運行的 JavaScript 代碼。與共享一切架構相反的是,在模塊頂部創建的變量不會不會被自動添加到全局共享作用域,這個變量僅在模塊的頂級作用域中存在,而且模塊必須導出一些外部代碼可以訪問的元素,如變量或函數。模塊也可以從其他模塊導入綁定。

另外兩個模塊的特性與作用域關係不大,但也很重要。首先,在模塊的頂部, this 的值是 undefined ;其次,模塊不支持 HTML 風格的代碼註釋,這是從早期瀏覽器殘留下來的 JavaScript 特性。

腳本,也就是任何不是模塊的 JavaScript 代碼,則缺少這些特性。模塊和其他 JavaScript 代碼之間的差異可能乍一看不起眼,但是它們代表了 JavaScript 代碼加載和求值的一個重要變化。模塊真正的魔力所在是僅導出和導入你需要的綁定,而不是將所有東西都到一個文件。只有很好地理解了導出和導入才能理解模塊與腳本的區別。

導出的基本語法

可以用 export 關鍵字將一部分已發佈的代碼暴露給其他模塊,在最簡單的用例中,可以將 export 放在任何變量、函數或類聲明的前面,以將它們從模塊導出,像這樣:

//導出數據
export var color = "red";
export let name = "Nicholas";
export const magicNumber = 7;

//導出函數
export function sum(num1,num2){
    return num1 + num2;
}

//導出類
export class Rectangle {
    constructor(length,width){
        this.length = length;
        this.width = width;
    }
}

//這個函數是模塊私有的
function subtract(num1,num2){
    return num1 - num2;
}

//定義一個函數...
function multiply(num1,num2){
     return num1 * num2;
} 

//...之後將它導出
export multiply;

在這個示例中需要注意幾個細節,除了 export 關鍵字外,每一個聲明與腳本中的一模一樣。因爲導出的函數和類聲明需要有一個名稱,所以代碼中的每一個函數或類也確實有這個名稱。除非用 default 關鍵字,否則不能用這個語法導出匿名函數或類(隨後在“模塊的默認值”中會詳細講解)。

另外,我們看 multiply() 函數,在定義它時沒有馬上導出它。由於不必總是導出聲明,可以導出引用,因此這段代碼可以運行。此外,請注意,這個示例並未導出 subtract() 函數,任何未顯示導出的變量、函數或類都是模塊私有的,無法從模塊外部訪問。

導入的基本語法

從模塊中導出的功能可以通過 import 關鍵字在另一個模塊中訪問, import 語句的兩個部分分別是: 要導入的標識符和標識符應當從哪個模塊導入。

import { identifier1, identifier2 } from "./example.js";

import 後面的大括號表示從給定模塊導入的綁定,關鍵字 from 表示從哪個模塊導入給定的模塊,該模塊由表示模塊路徑的字符串指定(被稱作模塊說明符)。瀏覽器使用的路徑格式與傳統 <script> 元素的相同,也就是說,必須把文件擴展名也加上。另一方面, Node.js 則遵循基於文件系統前綴區分本地文件和包的慣例。例如, example 是一個包而 ./example.js 是一個本地文件。

備註: 導入綁定的列表看起來與結構對象很相似,但它不是。

當從模塊中導入一個綁定時,它就好像使用 const 定義的一樣。結果是你無法定義一個同名變量(包括導入另一個同名綁定),也無法在 import 語句前使用標識符或改定綁定的值。

導入單個綁定

假設前面的示例在一個名爲 “example.js”的模塊中,我們可以導入並以多種方式使用這個模塊中的綁定。舉例來說,可以只導入一個標識符:

//只導入一個
import { sum } from "./example.js";

console.log(sum(1,2));       //3

sum = 1;                    //拋出一個錯誤

儘管 example.js 導出的函數不止一個,但這個示例導入的卻只有 sum() 函數。如果嘗試給 sum 賦新值,結果拋出一個錯誤,因爲不能給導入的綁定重新賦值。

備註: 爲了最好地兼容多個瀏覽器和 Node.js 環境,一定要在字符串之前包含 /./../來表示要導入的文件。

導入多個綁定

如果你想從示例模塊導入多個綁定,則可以明確地將它們列出如下:

//導入多個
import { sum, multiply, magicNumber } from "./example.js";
console.log(sum(1, magicNumber));      //8
console.log(multiply(1, 2));          //2

在這段代碼中,從 example 模塊導入3個綁定: sum、multiply和 magicNumber。之後使用它們,就像它們在本地定義一樣。

導入整個模塊

特殊情況下,可以導入整個模塊作爲一個單一的對象。然後所有的導出都可以作爲對象的屬性使用。例如:

//導入一切
import * as example from "./example.js";
console.log(example.sum(1, example.magicNumber));      //8
console.log(example.multiply(1, 2));          //2

在這段代碼中,從 example.js 中導出的所有綁定被加載到一個被稱作 example 的對象中。指定的導出( sum() 函數 mutiply() 函數和 magicNumber ) 之後會作爲 example 的屬性被訪問。這種導入格式被稱作命名空間導入(namespace import)。因爲 example.js 文件中不存在 example 對象,故而它作爲 example.js 中所有導出成員的命名空間對象而被創建。

但是,請記住,不管在 import 語句中把一個模塊寫了多少次,該模塊將只執行一次。導入模塊的代碼執行後,實例化過的模塊被保存在內存中,只要另一個 import 語句引用它就可以重複使用它。思考一下幾點:

import { sum } from "./example.js";
import { multiply } from "./example.js";
import { magicNumber } from "./example.js";

儘管在這個模塊中有3個 import 語句,但 example.js 將只執行一次。如果同一個應用程序中的其他模塊也從 example.js 導入綁定,那麼那些模塊與此代碼將使用相同的模塊實例。

模塊語法的限制 export 和 import 的一個重要的限制是,它們必須在其它語句和函數之外使用。例如,
下面代碼會給出一個語法錯誤:

if(flag){
    export flag;      //語法錯誤
}
export 語句不允許出現在 if 語句中,不能有條件導出或以任何方式動態導出。模塊

語法存在的一個原因是要讓 Javascript 引擎靜態地確定哪些可以導出。因此只能在模塊頂
部使用 export。

同樣,不能在一條語句中使用 import,只能在頂部使用它。下面這段代碼也會給出語法

錯誤:

function tryImport(){
    import flag from "./example.js";    //語法錯誤
}

出於同樣的原因不能動態地導入或導出綁定。export 和 import 關鍵字被設計成靜態的,因而像文本編輯器這樣的工具可以輕鬆地識別模塊中那些信息是可用的。

導入綁定的一個微妙怪異之處

ECMAScript 6import 語句爲變量、函數和類創建的是隻讀綁定,而不是像正常變量一樣簡單地引用原始綁定。標識符只有在被導出的模塊中可以修改,即便是導入綁定的模塊也無法更改綁定的值。例如,假設我們使用這個模塊:

export var name = "Nicholas";
export function setName(newName){
    name = newName;
}

當導入這兩個綁定後, setName() 函數可以改變 name 的值:

import { name, setName } from "./example.js";

console.log(name);     //"Nicholas"
setName("Greg");
console.log(name);     //"Greg"

name = "Nicholas";     //拋出錯誤 

調用 setName("Greg") 時會回到導出 setName() 的模塊中去執行,並將 name 設置爲 "Greg"。請注意,此更改會自動在導入的 name 綁定上體現。其原因是, name 是導出的 name 標識符的本地名稱。本段代碼中所使用的 name 和模塊中導入的 name 不是同一個。

導出和導入時重名時

有時候,從一個模塊導入變量、函數或者類時,我們可能不希望使用它們的原始名稱。幸運的是,可以在導出過程和導入過程中改變導出元素的名稱。

在第一種情況中,假設要使用不同的名稱導出一個函數,則可以用 as 關鍵字來指定函數在模塊外應該被稱爲什麼名稱。

function sum(num1, num2){
    return num1 + num2;
}

export { sum as add };

在這裏,函數 sum() 是本地名稱, add() 是導出時使用的名稱。也就是說,當另一個模塊要導入這個函數時,必須使用 add 這個名稱:

import { add } from "./example.js";

如果模塊想使用不同的名稱來導入函數,也可以使用 as 關鍵字:

import { add as sum } from "./example.js";
console.log(typeof add);           //"undefined"
console.log(sum(1, 2));           // 3

這段代碼導入 add() 函數時使用了一個導入名稱來重命名 sum() 函數(當前上下文中的本地名稱)。導入時改變函數的本地名稱意味着即使模塊導入了 add() 函數,在當前模塊中也沒有 add() 標識符。

模塊的默認值

由於在諸如 CommonJS ( 瀏覽器外的另一個 JavaScript 使用規範 )的其他模塊系統中,從模塊中導出和導入默認值是一個常見的做法,該語法被進行了優化。模塊的默認值指的是通過 default 關鍵字指定的單個變量、函數或類,只能爲每個模塊設置一個默認的導出值,導出時多次使用 default 關鍵字是一個語法錯誤。

導出默認值

下面是一個使用 default 關鍵字的簡單示例:

export default function(num1, num2){
    return num1 + num2; 
}

這個模塊導出了一個函數作爲它的默認值, default 關鍵字表示這是一個默認的導出,由於函數被模塊所代表,因而它不需要一個名稱。

也可以在 export default 之後添加默認導出值的標識符,就像這樣:

function sum(num1, num2){
    return num1 + num2; 
}
export default sum;

先定義 sum() 函數,然後再將其導出爲默認值,如果需要計算默認值,則可以使用這個方法。

爲默認導出值指定標識符的第三種方法是使用重命名方法,如下所示:

function sum(num1, num2){
    return num1 + num2; 
}
export { sum as default };

在重命名導出時標識符 default 具有特殊含義,用來指定模塊的默認值。由於 defaultJavaScript 中的默認關鍵字,因此不能將其用於變量、函數或類的名稱;但是,可以將其用作屬性名稱。所以用 default 來重命名模塊是爲了儘可能與非默認導出的定義一致。如果想在一條導出語句中同時指定多個導出(包括默認導出),這個語法非常有用。

導入默認值

可以使用以下語法從一個模塊導入一個默認值:

//導入默認值
import sum from "./example.js";

console.log(sum(1, 2));      //3

這條 import 語句從模塊 example.js 中導入了默認值,請注意,這裏沒有使用大括號,與你見過的非默認導入的情況不同。本地名稱 sum 用於表示模塊導出的任何默認函數,這種語法是最純淨的,ECMAScript 6 的創建者希望它能夠成爲 web 上主流的模塊導入形式,並且可以使用已有的對象。

對於導出默認值和一或多個非默認綁定的模塊,可以用一條語句導入所有導出的綁定。例如,假設有以下這個模塊:

export let color = "red";

export default function(num1, num2){
    return num1 + num2;
}

可以用以下這條 import 語句導入 color 和默認函數:

import sum, { color } from "./example.js";

console.log(sum(1, 2));     //3
console.log(color);        //"red"

用逗號將默認的本地名稱與大括號包裹的非默認值分隔開,請記住,在 import 語句中,默認值必須排在非默認值之前。

與導出默認值一樣,也可以在導入默認值是使用重命名語法:

import { default as sum, color } from "./example.js";

console.log(sum(1, 2));     //3
console.log(color);        //"red"

在這段代碼中,默認導出(export)值被重命名爲 sum,並且還導入了 color。該示例與之前的示例相同。

重新導出一個綁定

最終,可能需要重新導出模塊已經導入的內容。例如,你正在用幾個小模塊創建一個庫,則可以用已經討論的模式重新導出已經導入的值,如下所示:

import { sum } from "./example.js";
export { sum }

雖然這樣可以運行,但只通過一條語句也可以完成同樣的任務:

export { sum } from "./example.js";

這種形式的 export 在指定的模塊中查找 sum 聲明,然後將其導出。當然對於同樣的值你也可以不同的名稱導出:

export { sum as add } from "./example.js";

這裏的 sum 是從 example.js 導入的,然後再用 add 這個名字將其導出。如果想導出另一個模塊中的所有值,則可以使用 *模式:

export * from "./example.js";

導出一切是指導出默認值及所有命名導出值,這可能會影響你可以從模塊導出的內容。例如,如果 example.js 有默認的導出值,則使用此語法時將無法定義一個新的默認導出。

無綁定導入

某些模塊可能不導出任何東西,相反,它們可能只修改全局作用域中的對象。儘管模塊中的頂層變量、函數和類不會自動地出現在全局作用域中,但這並不意味着模塊無法訪問全局作用域。內建對象(如Array和Object)的共享定義可以在模塊中訪問,對這些對象所做的更改將反映在其他模塊中。

例如,要向所有數組添加 pushAll() 方法,則可以定義如下所示的模塊:

//沒有export或import的模塊代碼
Array.prototype.pushAll = function(items){
    
    //items必須是一個數組
    if (!Array.isArray(items)){
        throw new TypeError("參數必須是一個數組。")
    }
    
    //使用內建的push()和展開運算符
    return this.push(...items);
};

即使沒有任何導出和導入的操作,這也是一個有效的模塊。這段代碼即可用作模塊也可以用作腳本。由於它不導出任何東西,因而你可以使用簡化的導入操作來執行模塊代碼。而且不導入任何的綁定:

import "./example.js";

let colors = ["red", "green", "blue"];
let items = [];

items.pushAll(colors);

這段代碼導入並執行了模塊中包含的 pushAll() 方法,所以 pushAll() 被添加到數組的原型,也就是說現在模塊中的所有數組都可以使用 pushAll() 方法了。

備註:無綁定導入最有可能被應用於創建 PolyfillShim

加載模塊

雖然 ECMAScript 6 定義了模塊的語法,但它並沒有定義如何加載這些模塊。這正是規範複雜性的一個體現,應有不同的實現環境來決定。ECMAScript 6 嘗試爲所有 JavaScript 環境創建一套統一的標準,它只規定了語法,並將加載機制抽象到一個未定義的內部方法 HostResolveImportedModule 中。Web 瀏覽器和 Node.js 開發者可以通過對各自環境的認知來決定如何實現 HostResolveImportedModule

在Web瀏覽器中使用模塊

即使在 ECMAScript 6 出現以前,Web 瀏覽器有多種方式可以將 JavaScript 包含在Web 應用程序中,這些腳本加載的方法分別是:

  • <script> 元素中通過 src 屬性指定一個加載代碼的地址來加載 JavaScript 代碼文件。
  • JavaScript 代碼內嵌到沒有 src 屬性的 script 元素中。
  • 通過 Web WorkerService Worker 的方法加載並執行 JavaScript 代碼文件。

爲了完全支持模塊功能,Web 瀏覽器必須更新着這些機制,下面我們來總結一下。

<script> 中使用模塊

<script> 元素的默認行爲是將 JavaScript 文件作爲腳本加載,而非作爲模塊加載,當 type 屬性缺失或包含一個 JavaScript 內容類型(如"text/javascript")時就會發生這種情況。 script 元素可以執行內聯代碼或加載 src 中指定的文件,當 type 屬性的值爲“module”時支持加載模塊。將 type 設置爲“module”可以讓瀏覽器將所有內聯代碼或包含在 src 指定的文件中的代碼按照模塊而非腳本的方式加載。這裏有個簡單的示例:

<!--加載一個 JavaScript 模塊文件 -->
<script type="module" src="module.js"></script>

<!--內聯引入一個模塊 --> 
<script type="module">

import { sum } from "./example.js";

let result = sum(1, 2);

</script>

此示例中的第一個 script 元素使用 src屬性加載了一個外部的模塊文件,它與加載腳本之間的唯一區別是 type 的值是“module”。第二個 script 元素包含了直接嵌入在網頁的模塊。變量 result 沒有暴露到全局作用域,它只存在於模塊中(由 <script> 元素定義 ),因此不會被添加到 window 作爲它的屬性。

如你所見,在 web 頁面中引入模塊的過程類似於引入腳本,相當簡單。但是,模塊yu實際的加載過程卻有一些不同。

注意:你可能已經注意到,“module”與“text/javascript” 這樣的內容類型並不相同。JavaScript 模塊文件與 JavaScript 腳本文件就有相同的內容類型,因此無法僅根據內容類型進行區分。此外,當無法識別 type 的值時,瀏覽器會忽略 <script> 元素,因此不支持模塊的瀏覽器將自動忽略 <script type="module"> 來提供良好的向後兼容性。

Web 瀏覽器中的模塊加載順序

模塊與腳本不同,它是獨一無二的,可以通過 import 關鍵字來指明其所依賴的其他文件,並且這些文件必須被加載進該模塊才能正確執行。爲了支持該功能, <script type="module">執行時自動應用 defer 屬性。 記載腳本文件時, defer 是可選屬性;加載模塊時,它就是必需屬性。一旦 HTML 解析器遇到具有 src 屬性的 <script type="module">,模塊文件便開始下載,直到文檔被完全解析模塊纔會執行。模塊按照它們出現在 HTML 文件中的順序執行,也就是說,無論模塊中包含的是內聯代碼還是指定 src 屬性,第一個 <script type="module"> 總是在第二個之前執行。例如:

<!-- 先執行這個標籤 -->
<script type="module" src="module1.js"></script>

<!-- 再執行這個標籤 --> 
<script type="module">

import { sum } from "./example.js";

let result = sum(1, 2);
</script>

<!-- 最後執行這個標籤 -->
<script type="module" src="module2.js"></script>

這3個 script 元素按照它們被指定的順序執行,所以模塊 module1.js 保證會在內聯模塊前執行,而內聯模塊保證會在 module2.js 模塊之前執行。

每個模塊都可以從一個或多個其他的模塊導入,這會使問題複雜化。因此,首先解析模塊以識別所有導入語句;然後,每個導入語句都觸發一次獲取過程(從網絡或從緩存),並且在所有導入資源都被加載和執行後纔會執行當前模塊。

<script type="module"> 顯示引入和用 import 隱式導入的所有模塊都是按需加載並執行的。在這個示例中,完整的加載順序如下:

1.加載並解析 module1.js。
2.遞歸下載並解析 module1.js中導入的資源。
3.解析內聯模塊。
4.遞歸下載並解析內聯模塊中導入的資源。
5.加載並解析 module2.js。
6.遞歸下載並解析 module2.js中導入的資源。

加載完成後,只有當文檔完全被解析之後纔會執行其他操作。文檔解析完成後,會發生以下操作:

1.遞歸執行 module1.js 中導入的資源。
2.執行 module1.js。
3.遞歸執行內聯模塊中導入的資源。
4.執行內聯模塊。
5.遞歸執行 module2.js中導入的資源。
6.執行 module2.js。

請注意,內聯模塊與其他兩個模塊唯一的不同是,它不必先下載模塊代碼。否則,加載導入資源和執行模塊的順序就是一樣的。

備註: <script type="module"> 元素會忽略 defer 屬性默認是存在的。

Web 瀏覽器中的異步模塊加載

你可能熟悉 <script> 元素上的 async 屬性。當其應用於腳本時,腳本文件將在文件完全加載並解析後執行。但是,文檔中 async 腳本的順序不會影響腳本執行的順序,腳本在下載完成後立即執行,而不必等待包含的文檔完成解析。

async 屬性也可以應用在模塊上,在 <script type="module"> 元素上應用 async 屬性會讓模塊以類似腳本的方式執行,唯一的區別是,在模塊執行前,模塊中所有的導入資源都必須下載下來。這可以確保只有當模塊執行所需的所有資源都下載完成後才執行模塊,但不能保證的是模塊的執行時機。請考慮以下代碼:

<!-- 無法保證這兩個哪個先執行 -->
<script type="module" async src="module1.js"></script>
<script type="module" async src="module2.js"></script>

在這個示例中,兩個模塊文件被異步加載。只是簡單地看這個代碼判斷不出哪個模塊先執行,如果 module1.js 首先完成下載(包括所有的導入資源),它將先執行;如果 module2.js 首先完成下載,那麼它將先執行。

將模塊作爲 Worker 加載

Worker, 例如 Web WorkerService Worker,可以在網頁上下文之外執行 JavaScript 代碼。創建新 Worker 的步驟包括:創建一個新的 Worker 實例(或其他的類),傳入 JavaScript 文件的地址。默認的加載機制是按照腳本的方式加載文件,如下所示:

//按照腳本的方式加載script.js
let worker = new Worker("script.js");

爲了支持加載模塊,HTML 標準的開發者向這些構造函數添加了第二個參數,第二個參數是一個對象,其 type 屬性的默認值爲“script”。可以將 type 設置爲“module”來加載模塊文件:

//按照模塊的方式加載module.js
let worker = new Worker("module.js",{ type:"module" });

在此示例中,給第二個參數傳入一個對象,其 type 屬性的值爲“module”,即按照模塊而不是腳本的方式加載 module.js。(這裏的 type 屬性是爲了模仿 <script> 標籤的 type 屬性,用以區分模塊和腳本。)所有瀏覽器中的 Worker 類型都支持第二個參數。

Worker 模塊通常與 Worker 腳本一起使用,但也有一些例外。首先,Worker 腳本只能從與引用的網頁相同的源加載,但 Worker 模塊不會完全受限,雖然 Worker 模塊具有相同的默認限制,但它們還是可以將加載並訪問具有適當的跨域資源共享(CORS)頭的文件;其次儘管 Worker 腳本可以使用 self.importScripts() 方法將其他腳本加載到 Worker 中, 但 self.importScripts() 卻始終無法加載 Worker 模塊,因爲應該使用 import 來導入。

瀏覽器模塊說明符解析

之前的所有示例,模塊說明符(module specifier)使用的都是相對路徑(例如,字符串“./example.js”),瀏覽器要求模塊說明符具有以下幾種格式之一:

  • / 開頭的解析爲從根目錄開始。
  • ./ 開頭的解析爲從當前目錄開始。
  • ../ 開頭的解析爲從父目錄開始。
  • URL格式。

例如,假設有一個模塊文件爲於https://www.example.com/modul...:

//從 https://www.example.com/modules/example1.js導入
import { first } from "./example1.js";

//從 https://www.example.com/example2.js導入
import { second } from "../example2.js";

//從 https://www.example.com/example3.js導入
import { third } from "/example3.js";

//從 https://www2.example.com/example4.js導入
import { fourth } from "https://www2.example.com/example4.js";

此示例中的每個模塊說明符都適用於瀏覽器,包括最後一行中完整的 URL。(爲了支持跨域加載,只需確保 www2.example.comCORS 頭的配置是正確的。)儘管尚未完成的模塊加載器規範將提供解析其他格式的方法,但目前,這些是瀏覽器默認情況下唯一可以解析的模塊說明符的格式。

故此,一些看起來正常的模塊說明符在瀏覽器中實際上是無效的,並且會導致錯誤,例如:

//無效的,沒有以/,./或../開頭
import { first } from "example.js";

//無效的,沒有以/,./或../開頭
import { second } from "example/index.js";

由於這兩個模塊說明符的格式不正確(缺少正確的起始字符),因此它們無法被瀏覽器記載,即使在 <script> 標籤中用作 src 的值時二者都可以正常工作。 <script>import 之間的這種行爲差異是有意爲之。

總結

ECMAScript 6 語言中的模塊是一種打包和封裝功能的方式,模塊的行爲與腳本不同,模塊不會將它的頂級變量、函數和類修改爲全局作用域,而且 this 的值爲 undefined。要實現這個行爲,需通過不同的模式來加載模塊。 必須導出所有要讓模塊使用者使用的功能,變量、函數和類都可以導出,每個模塊還可以有一個默認的導出值。導出後,另一個模塊可以導入部分或所有導出的名稱,這些名稱表現得像是通過 let 定義的,運行起來與塊級作用域綁定一樣,在同一個模塊中無法重新聲明它們。

如果模塊只操作全局作用域,則不需要導出任何值。實際上,導入這樣一個模塊不會將任何綁定引入到當前的模塊作用域。

由於模塊必須運行在不同的模式下,因此瀏覽器引入 <script type="module"> 來表示模塊中應該執行的源文件或內聯代碼。通過 <script type="module"> 加載的模塊文件默認具有 defer 屬性。在文檔完全被解析之後,模塊也按照它們在包含文檔中出現的順序依次執行。

最後祝願所有的前端攻城獅新年快樂,願大家狗年旺旺,前途似錦,單身狗在狗年告別單身,歡迎大家加入前端全棧技術交流羣 544587175,相互交流,共同進步。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章