前端插拔式 SPA 應用架構實現方案

ES2049發表於2018-08-17

背景

隨著網際網路雲的興起,一種將多個不同的服務集中在一個大平臺上統一對外開放的概念逐漸為人熟知,越來越多與雲相關或不相關的中後臺管理系統或企業級資訊系統曾經或開始採用了這種「統一平臺」的形式。同時,前端領域保持著高速發展,早期的 jQuery+Backbone+Bootstrap 的 MVC 解決方案支撐起了業務相當長的一段時間;後來,Angular、Ember 等 MVVM 框架開始嶄露頭角,前後端分離和前端元件化的思想在此時達到了鼎盛期。而在國內,Vue 框架憑著其簡潔易懂的 API 和出色的周邊生態支援獨領鰲頭,越來越多的中小型企業和開發者們開始轉向 Vue 陣營;與此同時,在設計上獨樹一幟的純 View 層框架 React 開始興起,其充滿技術感的 Diff DOM 思想吸引了大批開發者,成為各大技術社群最火爆的話題,其周邊生態也隨之快速發展,成為了各大公司搭建技術棧時的首選框架。

回到平臺的話題。一個整合了不同業務的大平臺,很多情況下都是將業務拆分成多個子系統進行開發,最後由平臺提供統一的入口。而在當前快速變化的前端大環境下,此類平臺需要考慮以下幾個難題:

  1. 怎樣將不同業務子系統集中到一個大平臺上,統一對外開放?
  2. 如何給不同使用者賦予許可權讓其能夠訪問平臺的特定業務模組同時禁止其訪問無許可權的業務模組?
  3. 如何快速接入新的子系統,並對子系統進行版本管理,保證功能同步?
  4. 針對於老系統,如何實現從 Backbone 技術棧到 React 技術棧或 Vue 技術棧的平滑升級?

接下來,我將分別基於這幾個問題介紹我們的實現方案。

產品模型

首先我們來討論第一個問題:怎樣將不同業務子系統集中到一個大平臺上,統一對外開放?

如下圖所示,假設我們有三個業務子系統,使用者如果要使用三個系統中的不同功能,他就需要同時在三個系統中登入然後來回切換進行操作。

插圖1

而實際上理想的狀態是:A、B、C 三個子系統在同一個大平臺上,通過選單提供入口進入,使用者可以自由訪問任意一個子系統的頁面。如下圖所示:

插圖2

注意到上圖中我們給 A、B、C 都標記了 App(Application),把大平臺標記為了 Product,以下為了方便說明,我們把每個子系統都稱為 App,把整合子系統的平臺稱為 Product。

事實上,對於真正的業務場景,除了使用者體驗的改善,圖 2 所示系統還有很多優勢,比如果企業想按業務模組售賣產品,第二種方式顯然更好,使用者支付模組費用後賦予其模組許可權就可以使用新模組了,而不是提供給使用者一個新系統。除此以外,對企業來說避免部署獨立的業務系統也就意味著省掉了域名、伺服器、運維方面的資源,節省了企業成本。

架構方案

確定了 Product 包含 App 的產品模型後,我們接下來要考慮以怎樣的一種形式,讓每個 App 的訪問都能夠在 Product 下實現無縫切換。

如下圖所示,在訪問頁面時,我們為訪問路徑附加上了應用字首,標識當前訪問的是哪個 App,App 路徑字首之後才是當前訪問的頁面路徑,這是一個前提約定

插圖3

而從 Product 角度來看,我們希望使用者在使用平臺時,感受不到各個 App 在切換時是在切換各系統模組,所以 Product 需要控制所有 App 的檢視渲染時機,即:Product 需統一管理所有 App 的檢視路由。

同時,為了給不同許可權使用者展現不同的檢視頁面,我們把從後端返回的使用者許可權資料也傳入 Product,Product 會自動過濾掉沒有許可權的路由,如下圖所示:

插圖4

這裡,因為需要讓各 App 之間的切換對使用者來說就如同切換一個系統應用的各個頁面,我們採用了單頁面應用(SPA)的形式實現 Product 的路由控制。

整個方案的架構如下圖所示:

插圖5

在這個架構方案下,各子業務模組可以根據需要動態加入大平臺下,不需要時遮蔽訪問路徑字首即可;對平臺系統而言,各子業務模組如同一個個功能外掛,即插即用,不用即拔。這種插拔式的思想由來已久,我們稱之為「插拔式應用架構」。插拔式應用架構方案和傳統前端架構相比有以下幾個優勢:

  • 業務模組分散式開發,程式碼倉庫更易管理。
  • 業務模組(App)移植性強,可單獨部署,也可整合到大平臺(Product)下。
  • 模組程式碼高內聚,更專注業務。
  • 符合開閉原則,新模組的接入不需要修改已有模組,不會影響其他模組的功能。

資源許可權管理

在介紹架構方案的具體實現之前,我們需要先做些準備工作,先來看下開頭我們提出的第二、三兩個問題。

首先是第二個問題:如何給不同使用者賦予許可權讓其能夠訪問平臺的特定業務模組同時禁止其訪問無許可權的業務模組?

上文中簡單提到了後端將訪問許可權資料傳入 Product,我們的具體做法是每個 App 將自己的全量路由路徑傳入 Product ,而在啟動平臺(Product)時,Product 會從後端根據當前登入使用者獲取其有許可權的路由路徑,當訪問 App 任一路由時,會在首次與有許可權的路由路徑進行比對,比對失敗的路由路徑會自動導向無許可權的頁面檢視。

至於路由的許可權維護,可以做一個視覺化配置路由的管理頁面,許可權的細化程度根據自己的業務情況自定義即可。

其次是第三個問題:如何快速接入新的子系統,並對子系統進行版本管理,保證功能同步?

要回答這個問題,我們就要清楚每個 App 具體的接入方式。上文中有提到每個 App 的訪問依賴於當前的路徑字首,我們的具體做法是後端維護所有 App 基於 webpack 打出的 bundle 包的地址,並將這些包地址的配置對映關係傳入 Product,當首次訪問到某個 App 時,Product 會首先載入該 App 相關的 bundle 包,而其 js bundle 包內會呼叫全域性的 Product 注入自己的路由資訊,然後將後續的路由處理交給 Product 執行。

當然,上述的實現會涉及到渲染 App 檢視時的一些問題,在接下來的實現方案中我們會介紹到。

實現方案

上面我們討論了很多理論性的內容,接下來進入乾貨環節:如何實現一個插拔式應用框架?

根據上文中介紹一些實現思路,我們對將要實現的插拔式框架會先有一個大概的功能輪廓:

  • 自實現一個 Router,該 Router 需要在路由時根據路徑自動解析出 App 標識,然後基於標識動態載入 App 對應的資源包。
  • App 載入其 js 資源包後立即執行,自動向 Product 內注入 App 相關的路由資訊。
  • Router 在 App 載入完資源包後(script 指令碼會在載入後立即執行),嘗試根據路徑渲染 App 檢視頁面。
  • 切換路由後,如果切換至了其他子 App,原 App 應基於自身的生命週期,清除相關 DOM 和事件等邏輯。

簡單歸納一下,我們的插拔式應用框架應在實現上做出以下幾個功能點:動態路由、指令碼載入和排程、子應用檢視渲染、應用生命週期管理。

接下來我們分別一一介紹各功能點的實現思路。

動態路由

說起路由,對於不同的技術棧,有著不同的實現方案。如 Vue 有 vue-router,React 有 react-router 等。而為了適配各子 App 採用不同的技術體系開發的情形,我們需要將路由配置加以規範和統一管理。所以,我們需要重新設計一個 Router,這個 Router 必須能夠做到:動態注入路由且同時支援不同技術體系元件的渲染。

這裡,我們採用了靈活性較強的 universal-router,其 pathaction 的配置方式能夠讓我們很方便地進行自定義的路由邏輯處理。雖然它不支援動態注入路由,但其程式碼組織合理,配合大名鼎鼎的 history 庫,我很容易便實現了滿足自己需求的 Router。

如下圖所示:

插圖6

指令碼載入和排程

在完成動態路由的基本功能後,我們就要開始處理路由邏輯的第一步了:動態載入當前訪問 App 的指令碼等資源包。

首先我們先分析出處理流程:在開始路由時,我們需要根據請求路徑的第一段路徑名(如 /a/b 的第一段為 a)確定當前要路由的路徑對應的是哪一個 App,若對應的 App 尚未注入路由資訊,就需要動態載入 App 的資源包,待執行了 js 指令碼資源包後,再繼續執行後續的渲染邏輯。

App 的資源包可以有多種形式的打包方式,如 AMD、Commonjs、UMD 等。而為了相容 App 能夠分別單獨部署和整合至平臺兩種情況,且保持最簡化的依賴,我們仍舊採用基於 webpack 打出 UMD 包的形式——讓 JS 載入後立即執行即可,省去了如對 AMD 包載入器如 Requirejs 的依賴。

那麼,依託於瀏覽器自身的指令碼載入機制,我們的資源包載入器就很好實現了:分別使用 link 和 script 標籤在 head 和 body 標籤下動態插入資源包地址即可。

當然,也有人會考慮到資源包先後順序載入依賴的問題。一般情況下,webpack 打包時會自行處理依賴關係,如果對多個資源包外掛有先後執行順序的依賴需求(如 jQuery 外掛依賴),可在載入時做特殊的序列處理。

App 指令碼載入流程如下圖所示:

插圖7

應用檢視渲染

處理了 App 資源包的動態載入後,我們就要實現路由模組最核心的功能了:應用檢視的渲染。

首先,在上文介紹方案時,我們提到每個子 App 既要能支援單獨部署,又需要能夠接入 Product 內,在平臺上執行。所以,我們應該意識到:各 App 檢視的渲染應該交由每個子 App 自己完成,而不是由框架統一完成。

如果你對上面的結論感覺太突兀,那麼,請思考以下兩個問題:

  1. 如果框架統一渲染路由結果,那麼如何保證對 React Component、Backbone View 等各種不同形式元件的相容?
  2. 如果框架統一渲染路由結果,就需要引入渲染介面,那麼如何保證相容各子 App 的介面版本(如 ReactDOM 版本等)?

所以,為了體現框架兼顧不同技術體系 App 的插拔式設計思想,我們必須要將應用檢視的渲染從框架內抽離出去。

那麼,框架的路由在檢視渲染邏輯上還需要做什麼事呢?

我們很快就會想到檢視渲染邏輯抽離出去後存在的問題:各子 App 要自己實現渲染了,那框架提效的作用體現在了何處?渲染介面又該如何統一?

前文中提到了開閉原則,開閉原則最主要的設計思想就是物件導向設計。我們的解決方案就是:

  1. 提供一個 Application 基類,規範渲染介面,各子 App 在注入應用時必須注入繼承自 Application 基類的應用例項。
  2. 預設提供使用較廣的 React Application 和適用性較強的 Backbone Application 兩個渲染實現應用類(均繼承自 Application 基類)。

在各子 App 的入口 JS 檔案內,可以根據自己的技術體系直接例項化 ReactApplication 或 BackboneApplication,也可以繼承自 Application 基類自實現渲染介面。當然,如果自己的應用類使用較多,可以作為外掛貢獻出去。

Application 基類的示例程式碼:

// application/index.js
class Application {
  static DEFAULTS = {
    // ...
  }

  constructor(options = {}) {
    this._options = Object.assign({}, DEFAULTS, options);
  }

  start() {
    // 啟動應用,開啟 view 的路徑變化監聽事件
  }

  stop() {
    // 停止路徑變化監聽事件
  }

  renderLayout() {
    // 渲染布局的介面
  }

  render() {
    // 渲染主體內容的介面
  }

  // ...
}
複製程式碼

ReactApplication 類的實現示例程式碼:

// application/react/index.js
import Application from '../index.js';

class ReactApplication extends Application {
  render(err, children, params = {}) {
    if (err) {
      // 渲染錯誤頁
      throw err;
    }
    // React 和 ReactDOM 在例項化時由 App 自己傳入,便於各 App 自己控制 React 版本
    const { React, ReactDOM } = this._options;
    ReactDOM.render(children, this._container);
  }
}
複製程式碼

BackboneApplication 類的實現示例程式碼:

// application/backbone/index.js
import Application from '../index.js';

class BackboneApplication extends Application {
  render(err, viewAction, params = {}) {
    if (err) {
      // 渲染錯誤頁
      throw err;
    }
    if (viewAction.prototype && isFunction(viewAction.prototype.render)) {
      this._currentView = new viewAction(params);
      return this._currentView.render();
    }
    if (typeof viewAction.render === 'function') {
      return viewAction.render(params);
    }
  }
}
複製程式碼

將渲染邏輯交給各子 App 自己實現後,我們就可以避免在框架的 View 類中根據不同技術體系實現不同的渲染邏輯。如果子 App 換了 Backbone 和 React 之外的其他渲染方式,我們也不必修改框架的實現重新發布新的版本。

另外,除了應用例項外,我們還需要構造一個 Product 類,提供注入應用例項的入口。示例程式碼如下:

class Product {
  static registerApplication = (app) => {
    // 快取 app 例項,並注入 app 路由
  }
}
複製程式碼

在各子 App 的入口 JS 檔案內,呼叫 Product 類注入當前 app 例項(以 React App 為例):

// src/app.js
import React from 'react';
import ReactDOM from 'react-dom';
import { Product, ReactApplication } from 'plugin-pkg';

const app = new ReactApplication({
  React,
  ReactDOM,
  // ...
});

Product.registerApplication(app);
複製程式碼

應用生命週期管理

到這裡,從動態路由到檢視渲染,我們都已經有了具體的實現思路,現在考慮實際應用時的一個問題:在切換各子 App 時,上一個 App 的 DOM 會被替換,但相關的事件並未正確清除。拿 React 來說,我們直接替換掉 DOM 內容,但未正確觸發 React 元件的 UnMount 事件,Backbone View 的 destroy 回撥同理。

所以,我們需要為 Application 類新增 destroy 介面:

class Application {
  destroy() {
    // 在當前 App 例項切換出去時呼叫
  }
}
複製程式碼

除了銷燬事件,有時在 App 切換進來後也會需要一些統一處理,我們同時需要新增 ready 介面:

class Application {
  ready() {
    // 在當前 App 例項切換進來時呼叫
  }
}
複製程式碼

生命週期的處理實現,各 App 例項根據自己的實際情況自行實現相關邏輯即可。

框架在切換 App 時,需自動呼叫上一個應用例項的銷燬介面,然後在渲染 App 後,再自動呼叫當前 App 的準備介面。

構建配置

上面的內容都是插拔式框架需要實現的功能,另外,各子 App 在打包時也要統一配置。如框架的依賴應設為 external 的形式,在打包時不打入資源包。因為我們的各 App JS 資源包都是 UMD 包直接執行的形式,在實際執行時使用 Product 統一引入的框架包的全域性變數即可。

webpack 配置的示例程式碼如下:

// webpack.config.js
const path = require('path');

const resolveApp = relativePath => path.join(process.cwd(), relativePath);

module.exports = {
  entry: {
    bundle: resolveApp('src/app.js');
  },
  module: {
    // ...
  },
  plugins: [
    // ...
  ],
  externals: {
    'plugin-pkg': 'Plugin',
  },
};
複製程式碼

這樣,不但能相容獨立部署和整合入平臺兩種形式,也能在插入平臺模式下統一用平臺的插拔式框架包,便於平臺的統一升級。

總結

以上的插拔式應用設計是因為考慮到了相容不同技術體系的子業務模組,路由的實現稍顯繁複,指令碼的動態載入也比較簡單。在實際業務需求中,如果已經確定了統一技術體系,大部分情況下就不必考慮相容不同子業務模組的問題了,完全可以選定一種技術體系(如 Vue 或 React)來實現,多做的可能也只有許可權處理這一小塊。

所以,以上內容僅作參考,根據實際業務不同,設計出適合自己業務的插拔式方案,才是最好用的方案。

參考

文章可隨意轉載,但請保留此 原文連結 。 非常歡迎有激情的你加入 ES2049 Studio,簡歷請傳送至 caijun.hcj(at)alibaba-inc.com

相關文章