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

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

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

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

文章大綱vue


分層風格

沒有什麼問題是分層解決不了,若是解決不了, 就再加一層 —— 魯迅

不不,原話是: Any problem in computer science can be solved by anther layer of indirection.node

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

一個分層系統是按照層次來組織的,每一層爲在其之上的層提供服務,而且使用在其之下的層所提供的服務. 分層一般能夠解決什麼問題git

  • 是隔離業務複雜度與技術複雜度的利器. 典型的例子是網絡協議, 越高層越面向人類,越底層越面向機器。一層一層往上,不少技術的細節都被隱藏了,好比咱們使用HTTP時,不須要考慮TCP層的握手和包傳輸細節,TCP層不須要關心IP層的尋址和路由。github


  • 分離關注點和複用。減小跨越多層的耦合, 當一層變更時不會影響到其餘層。例如咱們前端項目建議拆分邏輯層和視圖層,一方面能夠下降邏輯和視圖之間的耦合,當視圖層元素變更時能夠儘可能減小對邏輯層的影響;另一個好處是, 當邏輯抽取出去後,能夠被不一樣平臺的視圖複用。web


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

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


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架構風格,除此以外還有各類衍生風格,例如MVPMVVMMVI(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的框架,例如Vuexngrx,在架構思想層次是一致的:


複製風格

基於複製(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)又稱爲"插件架構", 指的是軟件的內核相對較小,主要功能和業務邏輯都經過插件形式實現。內核只包含系統運行的最小功能。插件之間相互獨立,插件之間的通訊,應該降到最低,減小相互依賴。

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

前端領域比較典型的例子是WebpackBabelPostCSS以及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,要學會經過現象看本質,觸類旁通融會貫通,這纔是進階之道。

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

本文完!


擴展閱讀


相關文章
相關標籤/搜索