[轉] 透過現象看本質: 常見的前端架構風格和案例
所謂軟體架構風格,是指描述某個特定應用領域中系統組織方式的慣用模式。架構風格定義一個詞彙表和一組約束,詞彙表中包含一些元件及聯結器,約束則指出系統如何將構建和聯結器組合起來。軟體架構風格反映了領域中眾多系統所共有的結構和語義特性,並指導如何將系統中的各個模組和子系統有機的結合為一個完整的系統
沒多少人能記住上面的定義,需要注意的是本文不是專業討論系統架構的文章,筆者也還沒到那個水平. 所以暫時沒必要糾結於什麼是架構模式、什麼是架構風格。在這裡尚且把它們都當成一個系統架構上的套路, 所謂的套路就是一些通用的、可複用的,用於應對某類問題的方式方法. 可以理解為類似“設計模式”的東西,只是解決問題的層次不一樣
透過現象看本質,本文將帶你領略前端領域一些流行技術棧背後的架構思想。直接進入正題吧
文章大綱
分層風格
沒有什麼問題是分層解決不了,如果解決不了, 就再加一層 —— 魯迅
不不,原話是: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的關係比較曖昧,兩者是耦合在一起的。後面有人想,我們有了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\n' | \
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.js
module.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程式碼, 檢視層:
<h2>Todo</h2>
<div ng-controller="TodoListController as todoList">
<span>{{todoList.remaining()}} of {{todoList.todos.length}} remaining</span>
[ <a href="" ng-click="todoList.archive()">archive</a> ]
<ul class="unstyled">
<li ng-repeat="todo in todoList.todos">
<label class="checkbox">
<input type="checkbox" ng-model="todo.done">
<span class="done-{{todo.done}}">{{todo.text}}</span>
</label>
</li>
</ul>
<form ng-submit="todoList.addTodo()">
<input type="text" ng-model="todoList.todoText" size="30"
placeholder="add new todo here">
<input class="btn-primary" type="submit" value="add">
</form>
</div>
複製程式碼
邏輯層:
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\n');
}).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,要學會通過現象看本質,舉一反三融會貫通,這才是進階之道。
文章有錯誤之處,請評論指出
本文完!
來源:https://juejin.im/post/6844903943068205064