程序員透過現象看本質:常見的前端架構風格和案例

程序員透過現象看本質:常見的前端架構風格和案例

所謂軟件架構風格,是指描述某個特定應用領域中系統組織方式的慣用模式。架構風格定義一個詞彙表和一組約束,詞彙表中包含一些組件及連接器,約束則指出系統如何將構建和連接器組合起來。軟件架構風格反映了領域中眾多系統所共有的結構和語義特性,並指導如何將系統中的各個模塊和子系統有機的結合為一個完整的系統

沒多少人能記住上面的定義,需要注意的是本文不是專業討論系統架構的文章,筆者也還沒到那個水平. 所以暫時沒必要糾結於什麼是架構模式、什麼是架構風格。在這裡尚且把它們都當成一個系統架構上的套路, 所謂的套路就是一些通用的、可複用的,用於應對某類問題的方式方法. 可以理解為類似“設計模式”的東西,只是解決問題的層次不一樣

透過現象看本質,本文將帶你領略前端領域一些流行技術棧背後的架構思想。直接進入正題吧

文章大綱

  • 分層風格
  • Virtual DOM
  • Taro
  • 管道和過濾器
  • 中間件(Middleware)
  • 事件驅動
  • MV*
  • 家喻戶曉的MVC
  • Redux
  • 複製風格
  • 微內核架構
  • 微前端
  • 組件化架構
  • 其他
  • 擴展閱讀

分層風格

沒有什麼問題是分層解決不了,如果解決不了, 就再加一層 —— 魯迅不不,原話是: Any problem in computer science can be solved by anther layer of indirection.

分層架構是最常見的軟件架構,你要不知道用什麼架構,或者不知道怎麼解決問題,那就嘗試加多一層。

一個分層系統是按照層次來組織的,每一層為在其之上的層提供服務,並且使用在其之下的層所提供的服務. 分層通常可以解決什麼問題

  • 是隔離業務複雜度與技術複雜度的利器. 典型的例子是網絡協議, 越高層越面向人類,越底層越面向機器。一層一層往上,很多技術的細節都被隱藏了,比如我們使用HTTP時,不需要考慮TCP層的握手和包傳輸細節,TCP層不需要關心IP層的尋址和路由。
程序員透過現象看本質:常見的前端架構風格和案例

  • 分離關注點和複用。減少跨越多層的耦合, 當一層變動時不會影響到其他層。例如我們前端項目建議拆分邏輯層和視圖層,一方面可以降低邏輯和視圖之間的耦合,當視圖層元素變動時可以儘量減少對邏輯層的影響;另外一個好處是, 當邏輯抽取出去後,可以被不同平臺的視圖複用。


關注點分離之後,軟件的結構會變得容易理解和開發, 每一層可以被複用, 容易被測試, 其他層的接口通過模擬解決. 但是分層架構,也不是全是優點,分層的抽象可能會丟失部分效率和靈活性, 比如編程語言就有'層次'(此例可能不太嚴謹),語言抽象的層次越高,一般運行效率可能會有所衰減:

程序員透過現象看本質:常見的前端架構風格和案例

分層架構在軟件領域的案例實在太多太多了,咱講講前端的一些'分層'案例:

Virtual DOM

前端石器時代,我們頁面交互和渲染,是通過服務端渲染或者直接操作DOM實現的, 有點像C/C++這類系統編程語言手動操縱內存. 那時候JQuery很火:

程序員透過現象看本質:常見的前端架構風格和案例

後來隨著軟硬件性能越來越好、Web應用也越來越複雜,前端開發者的生產力也要跟上,類似JQuery這種命令式的編程方式無疑是比較低效的. 儘管手動操作 DOM 可能可以達到更高的性能和靈活性,但是這樣對大部分開發者來說太低效了,我們是可以接受犧牲一點性能換取更高的開發效率的.

怎麼解決,再加一層吧,後來React就搞了一層VirtualDOM。我們可以聲明式、組合式地構建一顆對象樹, 然後交由React將它映射到DOM:

所以說 VirtualDOM 更大的意義在於開發方式的轉變: 聲明式、 數據驅動, 讓開發者不需要關心 DOM 的操作細節(屬性操作、事件綁定、DOM 節點變更),換句話說應用的開發方式變成了view=f(state), 這對生產力的解放是有很大推動作用的; 另外有了VirtualDOM這一層抽象層,使得多平臺渲染成為可能。

當然VirtualDOM或者React,不是唯一,也不是第一個這樣的解決方案。其他前端框架,例如Vue、Angular基本都是這樣一個發展歷程。

上面說了,分層不是銀彈。我們通過ReactNative可以開發跨平臺的移動應用,但是眾所周知,它運行效率或者靈活性暫時是無法與原生應用比擬的。

Taro

Taro 和React一樣也採用分層架構風格,只不過他們解決的問題是相反的。React加上一個分層,可以渲染到不同的視圖形態;而Taro則是為了統一多樣的視圖形態: 國內現如今市面上端的形態多種多樣,Web、React-Native、微信小程序…… 針對不同的端去編寫多套代碼的成本非常高,這種需求催生了Taro這類框架的誕生. 使用 Taro,我們可以只書寫一套代碼, 通過編譯工具可以輸出到不同的端:

程序員透過現象看本質:常見的前端架構風格和案例


程序員透過現象看本質:常見的前端架構風格和案例

一開始VirtualDOM和DOM的關係比較曖昧,兩者是耦合在一起的。後面有人想,我們有了VirtualDOM這個抽象層,那應該能多搞點別的,比如渲染到移動端原生組件、PDF、Canvas、終端UI等等。

後來VirtualDOM進行了更徹底的分層,有著這個抽象層我們可以將VirtualDOM映射到更多類似應用場景:

程序員透過現象看本質:常見的前端架構風格和案例

所以說 VirtualDOM 更大的意義在於開發方式的轉變: 聲明式、 數據驅動, 讓開發者不需要關心 DOM 的操作細節(屬性操作、事件綁定、DOM 節點變更),換句話說應用的開發方式變成了view=f(state), 這對生產力的解放是有很大推動作用的; 另外有了VirtualDOM這一層抽象層,使得多平臺渲染成為可能。

當然VirtualDOM或者React,不是唯一,也不是第一個這樣的解決方案。其他前端框架,例如Vue、Angular基本都是這樣一個發展歷程。

上面說了,分層不是銀彈。我們通過ReactNative可以開發跨平臺的移動應用,但是眾所周知,它運行效率或者靈活性暫時是無法與原生應用比擬的。

Taro

Taro 和React一樣也採用分層架構風格,只不過他們解決的問題是相反的。React加上一個分層,可以渲染到不同的視圖形態;而Taro則是為了統一多樣的視圖形態: 國內現如今市面上端的形態多種多樣,Web、React-Native、微信小程序…… 針對不同的端去編寫多套代碼的成本非常高,這種需求催生了Taro這類框架的誕生. 使用 Taro,我們可以只書寫一套代碼, 通過編譯工具可以輸出到不同的端:

程序員透過現象看本質:常見的前端架構風格和案例

(圖片來源: 多端統一開發框架 - Taro)

管道和過濾器

在管道/過濾器架構風格中,每個組件都有一組輸入和輸出,每個組件職責都很單一, 數據輸入組件,經過內部處理,然後將處理過的數據輸出。所以這些組件也稱為過濾器,連接器按照業務需求將組件連接起來,其形狀就像‘管道’一樣,這種架構風格由此得名。

程序員透過現象看本質:常見的前端架構風格和案例

這裡面最經典的案例是*unix Shell命令,Unix的哲學就是“只做一件事,把它做好”,所以我們常用的Unix命令功能都非常單一,但是Unix Shell還有一件法寶就是管道,通過管道我們可以將命令通過標準輸入輸出串聯起來實現複雜的功能:

# 獲取網頁,並進行拼寫檢查。代碼來源於wiki
curl "http://en.wikipedia.org/wiki/Pipeline_(Unix)" | 
sed 's/[^a-zA-Z ]/ /g' | 
tr 'A-Z ' 'a-z
' | 
grep '[a-z]' | 
sort -u | 
comm -23 - /usr/share/dict/words | 
less

另一個和Unix管道相似的例子是ReactiveX, 例如RxJS. 很多教程將Rx比喻成河流,這個河流的開頭就是一個事件源,這個事件源按照一定的頻率發佈事件。Rx真正強大的其實是它的操作符,有了這些操作符,你可以對這條河流做一切可以做的事情,例如分流、節流、建大壩、轉換、統計、合併、產生河流的河流……

這些操作符和Unix的命令一樣,職責都很單一,只幹好一件事情。但我們管道將它們組合起來的時候,就迸發了無限的能力.

import { fromEvent } from'rxjs';
import { throttleTime, map, scan } from'rxjs/operators';
fromEvent(document, 'click')
 .pipe(
 throttleTime(1000),
 map(event => event.clientX),
 scan((count, clientX) => count + clientX, 0)
 )
 .subscribe(count =>console.log(count));

除了上述的RxJS,管道模式在前端領域也有很多應用,主要集中在前端工程化領域。例如'老牌'的項目構建工具Gulp, Gulp使用管道化模式來處理各種文件類型,管道中的每一個步驟稱為Transpiler(轉譯器), 它們以 NodeJS 的Stream 作為輸入輸出。整個過程高效而簡單。

程序員透過現象看本質:常見的前端架構風格和案例

不確定是否受到Gulp的影響,現代的Webpack打包工具,也使用同樣的模式來實現對文件的處理, 即Loader, Loader 用於對模塊的源代碼進行轉換, 通過Loader的組合,可以實現複雜的文件轉譯需求.

// webpack.config.jsmodule.exports = {
 ...
 module: {
 rules: [{
 test: /.scss$/,
 use: [{
 loader: "style-loader"// 將 JS 字符串生成為 style 節點
 }, {
 loader: "css-loader"// 將 CSS 轉化成 CommonJS 模塊
 }, {
 loader: "sass-loader"// 將 Sass 編譯成 CSS
 }]
 }]
 }
};
複製代碼

中間件(Middleware)

程序員透過現象看本質:常見的前端架構風格和案例

如果開發過Express、Koa或者Redux, 你可能會發現中間件模式和上述的管道模式有一定的相似性,如上圖。相比管道,中間件模式可以使用一個洋蔥剖面來形容。但和管道相比,一般的中間件實現有以下特點:

  • 中間件沒有顯式的輸入輸出。這些中間件之間通常通過集中式的上下文對象來共享狀態
  • 有一個循環的過程。管道中,數據處理完畢後交給下游了,後面就不管了。而中間件還有一個迴歸的過程,當下遊處理完畢後會進行回溯,所以有機會干預下游的處理結果。


我在谷歌上搜了老半天中間件,對於中間件都沒有得到一個令我滿意的定義. 暫且把它當作一個特殊形式的管道模式吧。這種模式通常用於後端,它可以乾淨地分離出請求的不同階段,也就是分離關注點。比如我們可以創建這些中間件:

  • 日誌:記錄開始時間 ⏸ 計算響應時間,輸出請求日誌
  • 認證:驗證用戶是否登錄
  • 授權:驗證用戶是否有執行該操作的權限
  • 緩存:是否有緩存結果,有的話就直接返回 ⏸ 當下遊響應完成後,再判斷一下響應是否可以被緩存
  • 執行:執行實際的請求處理 ⏸ 響應

有了中間件之後,我們不需要在每個響應處理方法中都包含這些邏輯,關注好自己該做的事情。下面是Koa的示例代碼:

const Koa = require('koa');
const app = new Koa();
// logger
app.use(async (ctx, next) => {
 await next();
 const rt = ctx.response.get('X-Response-Time');
 console.log(`${ctx.method}${ctx.url} - ${rt}`);
});
// x-response-time
app.use(async (ctx, next) => {
 const start = Date.now();
 await next();
 const ms = Date.now() - start;
 ctx.set('X-Response-Time', `${ms}ms`);
});
// response
app.use(async ctx => {
 ctx.body = 'Hello World';
});
app.listen(3000);

事件驅動

事件驅動, 或者稱為發佈-訂閱風格, 對於前端開發來說是再熟悉不過的概念了. 它定義了一種一對多的依賴關係, 在事件驅動系統風格中,組件不直接調用另一個組件,而是觸發或廣播一個或多個事件。系統中的其他組件在一個或多個事件中註冊。當一個事件被觸發,系統會自動通知在這個事件中註冊的所有組件.

這樣就

分離了關注點,訂閱者依賴於事件而不是依賴於發佈者,發佈者也不需要關心訂閱者,兩者解除了耦合

生活中也有很多發佈-訂閱的例子,比如微信公眾號信息訂閱,當新增一個訂閱者的時候,發佈者並不需要作出任何調整,同樣發佈者調整的時候也不會影響到訂閱者,只要協議沒有變化。我們可以發現,發佈者和訂閱者之間其實是一種弱化的動態的關聯關係

解除耦合目的是一方面, 另一方面也可能由基因決定的,一些事情天然就不適合或不支持用同步的方式去調用,或者這些行為是異步觸發的

JavaScript的基因決定事件驅動模式在前端領域的廣泛使用. 在瀏覽器和Node中的JavaScript是如何工作的? 可視化解釋 簡單介紹了Javascript的執行原理,其中提到JavaScript是單線程的編程語言,為了應對各種實際的應用場景,一個線程以壓根忙不過來的,事件驅動的異步方式是JavaScript的救命稻草.

瀏覽器方面,瀏覽器就是一個GUI程序,GUI程序是一個循環(更專業的名字是事件循環),接收用戶輸入,程序處理然後反饋到頁面,再接收用戶輸入…

用戶的輸入是異步,將用戶輸入抽象為事件是最簡潔、自然、靈活的方式。

需要注意的是:事件驅動和異步是不能劃等號的。異步 !== 事件驅動,事件驅動 !== 異步

擴展:

  • 響應式編程: 響應式編程本質上也是事件驅動的,下面是前端領域比較流行的兩種響應式模式:
  • 函數響應式(Functional Reactive Programming), 典型代表RxJS
  • 透明的函數響應式編程(Transparently applying Functional Reactive Programming - TFRP), 典型代表Vue、Mobx
  • 消息總線:指接收、發送消息的軟件系統。消息基於一組已知的格式,以便系統無需知道實際接收者就能互相通信

MV*

MV*架構風格應用也非常廣泛。我覺MV*本質上也是一種分層架構,一樣強調職責分離。其中最為經典的是MVC架構風格,除此之外還有各種衍生風格,例如MVP、MVVM、MVI(Model View Intent). 還有有點關聯Flux或者Redux模式。

家喻戶曉的MVC

程序員透過現象看本質:常見的前端架構風格和案例

如其名,MVC將應用分為三層,分別是:

  • 視圖層(View) 呈現數據給用戶
  • 控制器(Controller) 模型和視圖之間的紐帶,起到不同層的組織作用:
  • 處理事件並作出響應。一般事件有用戶的行為(比如用戶點擊、客戶端請求),模型層的變更
  • 控制程序的流程。根據請求選擇適當的模型進行處理,然後選擇適當的視圖進行渲染,最後呈現給用戶
  • 模型(Model) 封裝與應用程序的業務邏輯相關的數據以及對數據的處理方法, 通常它需要和數據持久化層進行通信

目前前端應用很少有純粹使用MVC的,要麼視圖層混合了控制器層,要麼就是模型和控制器混合,或者乾脆就沒有所謂的控制器. 但一點可以確定的是,很多應用都不約而同分離了'邏輯層'和'視圖層'。

下面是典型的AngularJS代碼, 視圖層:

Todo

{{todoList.remaining()}} of {{todoList.todos.length}} remaining

[ archive ]
  • {{todo.text}}

邏輯層:

angular.module('todoApp', [])
 .controller('TodoListController', function() {
 var todoList = this;
 todoList.todos = [
 {text:'learn AngularJS', done:true},
 {text:'build an AngularJS app', done:false}];
 todoList.addTodo = function() {
 todoList.todos.push({text:todoList.todoText, done:false});
 todoList.todoText = '';
 };
 todoList.remaining = function() {
 var count = 0;
 angular.forEach(todoList.todos, function(todo) {
 count += todo.done ? 0 : 1;
 });
 return count;
 };
 todoList.archive = function() {
 var oldTodos = todoList.todos;
 todoList.todos = [];
 angular.forEach(oldTodos, function(todo) {
 if (!todo.done) todoList.todos.push(todo);
 });
 };
});

至於MVP、MVVM,這些MVC模式的延展或者升級,網上都大量的資源,這裡就不予贅述。

Redux

Redux是Flux架構的改進、融合了Elm語言中函數式的思想. 下面是Redux的架構圖:

程序員透過現象看本質:常見的前端架構風格和案例

從上圖可以看出Redux架構有以下要點:

  • 單一的數據源.
  • 單向的數據流
    .


單一數據源, 首先解決的是傳統MVC架構多模型數據流混亂問題(如下圖)。單一的數據源可以讓應用的狀態可預測和可被調試。另外單一數據源也方便做數據鏡像,實現撤銷/重做,數據持久化等等功能

程序員透過現象看本質:常見的前端架構風格和案例

單向數據流用於輔助單一數據源, 主要目的是阻止應用代碼直接修改數據源,這樣一方面簡化數據流,同樣也讓應用狀態變化變得可預測。

上面兩個特點是Redux架構風格的核心,至於Redux還強調不可變數據、利用中間件封裝副作用、範式化狀態樹,只是一種最佳實踐。還有許多類Redux的框架,例如Vuex、ngrx,在架構思想層次是一致的:

程序員透過現象看本質:常見的前端架構風格和案例

複製風格

程序員透過現象看本質:常見的前端架構風格和案例

基於複製(Replication)風格的系統,會利用多個實例提供相同的服務,來改善服務的可訪問性和可伸縮性,以及性能。這種架構風格可以改善用戶可察覺的性能,簡單服務響應的延遲。

這種風格在後端用得比較多,舉前端比較熟悉的例子,NodeJS. NodeJS是單線程的,為了利用多核資源,NodeJS標準庫提供了一個cluster模塊,它可以根據CPU數創建多個Worker進程,這些Worker進程可以共享一個服務器端口,對外提供同質的服務, Master進程會根據一定的策略將資源分配給Worker:

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;
if (cluster.isMaster) {
 console.log(`Master ${process.pid} is running`);
 // Fork workers.for (let i = 0; i < numCPUs; i++) {
 cluster.fork();
 }
 cluster.on('exit', (worker, code, signal) => {
 console.log(`worker ${worker.process.pid} died`);
 });
} else {
 // Workers可以共享任意的TCP連接 // 比如共享HTTP服務器 
 http.createServer((req, res) => {
 res.writeHead(200);
 res.end('hello world
');
 }).listen(8000);
 console.log(`Worker ${process.pid} started`);
}
複製代碼

利用多核能力可以提升應用的性能和可靠性。我們也可以利用PM2這樣的進程管理工具,來簡化Node集群的管理,它支持很多有用的特性,例如集群節點重啟、日誌歸集、性能監視等。

複製風格常用於網絡服務器。瀏覽器和Node都有Worker的概念,但是一般都只推薦在CPU密集型的場景使用它們,因為瀏覽器或者NodeJS內置的異步操作已經非常高效。實際上前端應用CPU密集型場景並不多,或者目前階段不是特別實用。除此之外你還要權衡進程間通信的效率、Worker管理複雜度、異常處理等事情。

有一個典型的CPU密集型的場景,即源文件轉譯. 典型的例子是CodeSandbox, 它就是利用瀏覽器的Worker機制來提高源文件的轉譯性能的:

程序員透過現象看本質:常見的前端架構風格和案例

除了處理CPU密集型任務,對於瀏覽器來說,Worker也是一個重要的安全機制,用於隔離不安全代碼的執行,或者限制訪問瀏覽器DOM相關的東西。小程序抽離邏輯進程的原因之一就是安全性

其他示例:

  • ServerLess


微內核架構

程序員透過現象看本質:常見的前端架構風格和案例

微內核架構(MicroKernel)又稱為"插件架構", 指的是軟件的內核相對較小,主要功能和業務邏輯都通過插件形式實現。內核只包含系統運行的最小功能。插件之間相互獨立,插件之間的通信,應該降到最低,減少相互依賴。


微內核結構的難點在於建立一套粒度合適的插件協議、以及對插件之間進行適當的隔離和解耦。從而才能保證良好的擴展性、靈活性和可遷移性。

前端領域比較典型的例子是Webpack、Babel、PostCSS以及ESLint, 這些應用需要應對複雜的定製需求,而且這些需求時刻在變,只有微內核架構才能保證靈活和可擴展性。

以Webpack為例。Webpack的核心是一個Compiler,這個Compiler主要功能是集成插件系統、維護模塊對象圖, 對於模塊代碼具體編譯工作、模塊的打包、優化、分析、聚合統統都是基於外部插件完成的.

如上文說的Loader運用了管道模式,負責對源文件進行轉譯;那Plugin則可以將行為注入到Compiler運行的整個生命週期的鉤子中, 完全訪問Compiler的當前狀態。

Sean Larkin有個演講: Everything is a plugin! Mastering webpack from the inside out

程序員透過現象看本質:常見的前端架構風格和案例

這裡還有一篇文章專門寫了前端微內核架構模式的一些應用,推薦閱讀一下。

微前端

前幾天聽了代碼時間上左耳朵耗子的一期節目, 他介紹得了亞馬遜內部有很多小團隊,亞馬遜網站上一塊豆腐塊大小的區域可能是一個團隊在維護,比如地址選擇器、購物車、運達時間計算… 大廠的這種超級項目是怎麼協調和維護的呢?這也許就是微前端或者微服務出現的原因吧。

微前端旨在將單體前端分解成更小、更簡單的模塊,這些模塊可以被獨立的團隊進行開發、測試和部署,最後再組合成一個大型的整體。

程序員透過現象看本質:常見的前端架構風格和案例

微前端下各個應用模塊是獨立運行、獨立開發、獨立部署的,相對應的會配備更加自治的團隊(一個團隊幹好一件事情)。微前端的實施還需要有穩固的前端基礎設施和研發體系的支撐。


如果你想深入學習微前端架構,建議閱讀Phodal的相關文章,還有他的新書《前端架構:從入門到微前端》

組件化架構

組件化開發對現在的我們來說如此自然,就像水對魚一樣。以致於我們忘了組件化也是一種非常重要的架構思想,它的中心思想就是分而治之。按照Wiki上面的定義是:組件化就是基於可複用目的,將一個大的軟件系統按照分離關注點的形式,拆分成多個獨立的組件,主要目的就是減少耦合.

從前端的角度具體來講,如下圖,石器時代開發方式(右側), 組件時代(左側):

程序員透過現象看本質:常見的前端架構風格和案例

(圖片來源: www.alloyteam.com/2015/11/we-…)

按照Vue官網的說法: 組件系統是 Vue 的另一個重要概念,因為它是一種抽象,允許我們使用小型、獨立和通常可複用的組件構建大型應用。仔細想想,幾乎任意類型的應用界面都可以抽象為一個組件樹:

程序員透過現象看本質:常見的前端架構風格和案例

按照我的理解組件跟函數是一樣的東西,這就是為什麼函數式編程思想在React中會應用的如此自然

。若干個簡單函數,可以複合成複雜的函數,複雜的函數再複合成複雜的應用。對於前端來說,頁面也是這麼來的,一個複雜的頁面就是有不同粒度的組件複合而成的。


組件另外一個重要的特徵就是內聚性,它是一個獨立的單元,自包含了所有需要的資源。例如一個前端組件較包含樣式、視圖結構、組件邏輯:

程序員透過現象看本質:常見的前端架構風格和案例

其他

我終於編不下去了!還有很多架構風格,限於文章篇幅, 且這些風格主要應用於後端領域,這裡就不一一闡述了。你可以通過擴展閱讀了解這些模式

  • 面向對象風格: 將應用或系統任務分割為單獨、可複用、可自給的對象,每個對象都包含數據、以及對象相關的行為
  • C/S 客戶端/服務器風格
  • 面向服務架構(SOA): 指那些利用契約和消息將功能暴露為服務、消費功能服務的應用
  • N層/三層: 和分層架構差不多,側重物理層. 例如C/S風格就是一個典型的N層架構
  • 點對點風格

通過上文,你估計會覺得架構風格比設計模式或者算法好理解多的,正所謂‘大道至簡’,但是‘簡潔而不簡單’!大部分項目的架構不是一開始就是這樣的,它們可能經過長期的迭代,踩著巨人的肩膀,一路走過來才成為今天的樣子。

希望本文可以給你一點啟發,對於我們前端工程師來說,不應該只追求能做多酷的頁面、掌握多少API,要學會通過現象看本質,舉一反三融會貫通,這才是進階之道。

文章有錯誤之處,請評論指出

本文完!

原文作者:_sx_

原文鏈接:https://juejin.im/post/5d7ffad551882545ff173083

喜歡文章的記得點個關注不迷路~

私信+轉發回覆關鍵字(資料)送你一份前端面試題



分享到:


相關文章: