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

背景

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

回到平臺的話題。一個集成了不一樣業務的大平臺,不少狀況下都是將業務拆分紅多個子系統進行開發,最後由平臺提供統一的入口。而在當前快速變化的前端大環境下,此類平臺須要考慮如下幾個難題:html

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

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

產品模型

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

以下圖所示,假設咱們有三個業務子系統,用戶若是要使用三個系統中的不一樣功能,他就須要同時在三個系統中登陸而後來回切換進行操做。java

插圖1

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

插圖2

注意到上圖中咱們給 A、B、C 都標記了 App(Application),把大平臺標記爲了 Product,如下爲了方便說明,咱們把每一個子系統都稱爲 App,把集成子系統的平臺稱爲 Product。webpack

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

架構方案

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

以下圖所示,在訪問頁面時,咱們爲訪問路徑附加上了應用前綴,標識當前訪問的是哪一個 App,App 路徑前綴以後纔是當前訪問的頁面路徑,這是一個前提約定npm

插圖3

而從 Product 角度來看,咱們但願用戶在使用平臺時,感覺不到各個 App 在切換時是在切換各系統模塊,因此 Product 須要控制全部 App 的視圖渲染時機,即:Product 需統一管理全部 App 的視圖路由。

同時,爲了給不一樣權限用戶展示不一樣的視圖頁面,咱們把從後端返回的用戶權限數據也傳入 Product,Product 會自動過濾掉沒有權限的路由,以下圖所示:

插圖4

這裏,由於須要讓各 App 之間的切換對用戶來講就如同切換一個系統應用的各個頁面,咱們採用了單頁面應用(SPA)的形式實現 Product 的路由控制。

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

插圖5

在這個架構方案下,各子業務模塊能夠根據須要動態加入大平臺下,不須要時屏蔽訪問路徑前綴便可;對平臺系統而言,各子業務模塊如同一個個功能插件,即插即用,不用即拔。這種插拔式的思想由來已久,咱們稱之爲「插拔式應用架構」。插拔式應用架構方案和傳統前端架構相比有如下幾個優點:

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

資源權限管理

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

首先是第二個問題:如何給不一樣用戶賦予權限讓其可以訪問平臺的特定業務模塊同時禁止其訪問無權限的業務模塊?

上文中簡單提到了後端將訪問權限數據傳入 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

相關文章
相關標籤/搜索