在 Web 應用的執行時實現多分支並存和切換

語言: CN / TW / HK

背景

一般來說,SaaS 服務商提供的是標準化的產品服務,體現的是所有客戶的共性需求。然而,部分客戶(尤其是大客戶),會提出功能、UI 等方面的定製需求。針對這些定製需求,大體上有兩個解決方案。

第一個方案是提供應用程式 SDK,由客戶的開發團隊完成整個定製應用的開發和部署,SaaS 服務商提供必要的技術支援即可。此方案要求客戶的開發團隊具備較強的 IT 專業能力。

第二個方案則是由 SaaS 服務商的開發團隊在 SaaS 應用的基礎上進行二次開發,並部署。此方案主要面向 IT 專業能力較弱,或者僅需在 SaaS 應用的基礎上進行少量定製的客戶。然而,要支援這種定製方式,相當於要求 SaaS 服務商在 同一個應用中,針對不同的客戶執行不同分支的程式碼 。要達到這個目的,應用程式的架構也要進行相應的改造。本文主要講述改造的方案及其程式碼實現。

方案概覽

對於前後端分離的專案來說,經過構建,最終會生成 html、js、css 三種程式碼檔案。以基於 Vue.js 框架的專案為例,其構建出來的 index.html,內容與下面的程式碼相似:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <link href="https://cdn.my-app.net/sample/assets/css/chunk-0c7134a2.11fa7980.css" rel="prefetch">
  <link href="https://cdn.my-app.net/sample/assets/js/chunk-0c7134a2.02a43289.js" rel="prefetch">
  <link href="https://cdn.my-app.net/sample/assets/css/app.2dd9bc59.css" rel="preload" as="style">
  <link href="https://cdn.my-app.net/sample/assets/js/vendors~app.f1dba939.js" rel="preload" as="script">
  <link href="https://cdn.my-app.net/sample/assets/js/app.f7eb55ca.js" rel="preload" as="script">
  <link href="https://cdn.my-app.net/sample/assets/css/app.2dd9bc59.css" rel="stylesheet">
 </head>
 <body>
   <div id="app"></div>
   <script src="https://cdn.my-app.net/sample/assets/js/vendors~app.f1dba939.js"></script>
   <script src="https://cdn.my-app.net/sample/assets/js/app.f7eb55ca.js"></script>
 </body>
 </html>

實際上,index.html 只是訪問入口,主要作用就是載入 css 和 js 資源。換句話說: 任何的 html 頁面,只要載入了上述 css 和 js 資源,都可以執行這個應用 。

既然如此,只要 做一個應用入口頁,並根據客戶配置載入相應程式碼分支構建出來的 css 和 js 資源即可 。整體流程如下圖所示:

構建方案

入口頁要載入對應分支的 css 和 js 資源,首先需要一個資源列表。我們可以在構建流程增加一個步驟,把 js 和 css 的引用提取到一個 資源目錄檔案(index-assets.json) 中:

const fs = require('fs');
const content = fs.readFileSync('./dist/index.html', 'utf-8');

// 匹配 html 中的 js 或 css 引用標籤
const assetTags = content.match(/<(?:link|script).*?>/gi) || [];

let result = [];
assetTags.forEach((assetTag) => {
  const asset = {
    tagName: '',
    attrs: {}
  };

  // 解析標籤名
  if (/<(\w+)/.test(assetTag)) { asset.tagName = RegExp.$1; }

  // 解析屬性
  const reAttrs = /\s(\w+)=["']?([^\s<>'"]+)/gi;
  let attr;
  while ((attr = reAttrs.exec(assetTag)) !== null) {
    asset.attrs[attr[1]] = attr[2];
  }

  result.push(asset);
});

// 移除 preload 的資源,並把 prefetch 的資源放到 result 的最後面
const prefetches = [];
for (let i = 0, item; i < result.length;) {
  item = result[i];
  if (item.tagName === 'link') {
    if (item.attrs.rel === 'preload') {
      result.splice(i, 1);
      continue;
    } else if (item.attrs.rel === 'prefetch') {
      prefetches.push(result.splice(i, 1)[0]);
      continue;
    }
  }
  i++;
}
result = result.concat(prefetches);

fs.writeFileSync(
  './dist/index-assets.json',
  JSON.stringify({ list: result }),
  'utf-8'
);

執行指令碼後,就會生成資源目錄檔案,其內容為:

{
  "list": [
    {
      "attrs": {
        "href": "https://cdn.my-app.net/sample/assets/css/app.2dd9bc59.css",
        "rel": "stylesheet"
      },
      "tagName": "link"
    },
    {
      "attrs": {
        "src": "https://cdn.my-app.net/sample/assets/js/vendors~app.f1dba939.js"
      },
      "tagName": "script"
    },
    {
      "attrs": {
        "src": "https://cdn.my-app.net/sample/assets/js/app.f7eb55ca.js"
      },
      "tagName": "script"
    },
    {
      "attrs": {
        "href": "https://cdn.my-app.net/sample/assets/css/chunk-0c7134a2.11fa7980.css",
        "rel": "prefetch"
      },
      "tagName": "link"
    },
    {
      "attrs": {
        "href": "https://cdn.my-app.net/sample/assets/js/chunk-0c7134a2.02a43289.js",
        "rel": "prefetch"
      },
      "tagName": "link"
    }
  ]
}

在提取資源的過程中,移除了通過 link 標籤 preload 的資源,並把 prefetch 的資源放到了資源列表的末尾。具體原因會在後文說明。

此外,因為多個分支構建出來的程式碼都要上傳到 OSS,為了避免放在同一個目錄下互相覆蓋,就得再加一層分支目錄。

https://cdn.my-app.net/sample/ ${branch}/

所以,程式碼分支對應的資源目錄檔案路徑就是:

https://cdn.my-app.net/sample/ ${branch}/index-assets.json

載入方案

載入流程如上圖所示,接下來針對每一步詳述。

1.請求程式碼分支名

進入頁面後,攜帶客戶資訊(客戶標識、內容標識等)請求後端介面,該介面會返回程式碼分支名。實現如下:

// id 為客戶資訊
function getBranch(id) {
  // 如果請求後端介面超時(10s),就載入主分支
  const TIME_OUT = 10000;
  setTimeout(() => {
    loadAssetIndex('main');
  }, TIME_OUT);

  let branch;
  try {
    const response = await fetch(`/api/branch?id=${id}`);
    branch = (await response.json()).branch;
  } catch (e) {
    // 如果後端介面異常,就載入主分支
    branch = 'main';
  }

  // 載入資源目錄
  loadIndexAssets(branch);
}

除了實現基本的流程,以上程式碼還做了降級處理—— 如果後端介面超時或響應異常,就載入主分支,避免頁面白屏 。

2.載入資源目錄

載入指定分支名的資源目錄。實現如下:

// 用於避免重複載入
let status = 0;

function loadIndexAssets(branch) {
  if (status) { return; }
  status = 1;

  let list;
  try {
    const response = await fetch(`https://cdn.my-app.net/sample/${branch}/index-assets.json`);
    list = (await response.json()).list;
  } catch (e) {
    if (branch !== 'main') {
      status = 0;
      loadAssetIndex('main');
    }
    return;
  }
  status = 2;
  loadFiles(list);
}

同樣地,以上程式碼也做了降級處理—— 如果特定分支名的資源目錄檔案載入失敗,就會載入主分支的資源目錄檔案,避免頁面白屏 。

3.載入資源

遍歷資源列表,把 css 和 js 都載入到頁面上。程式碼實現如下:

function loadFiles(list) {
  list.forEach(function(item) {
    const elt = doc.createElement(item.tagName); 
    // 指令碼有依賴關係,要按順序載入
    if (item.tagName === 'script') { elt.async = false; }

    for (const name in item.attrs) {
      elt.setAttribute(name, item.attrs[name]);
    }
    doc.head.appendChild(elt);
  });
}

需要注意的是,對於動態建立的 script 節點來說,它的 async 屬性預設為 true。也就是說,這些 script 會被並行請求,並儘快解析和執行, 執行順序是未知的 。然而,資源目錄中的 js 是有依賴關係的,後面的 js 依賴於前面的 js。因此,必須把 script 節點的 async 設為 false,讓其按順序解析和執行。

指令碼順利執行後,應用就會初始化。

4.入口頁

為了讓讀者更好地理解整個過程,上述載入分支資源的程式碼是用 ES6 編寫的,並且會用到如 fetch、Promise、async、await 等特性。從相容性的角度考慮,這段程式碼需要經過 Babel 的轉譯,轉譯的過程中會插入一些額外的程式碼。然而,這段程式碼會阻塞後續的流程,應儘可能輕量化。因此,實際開發的時候是採用 ES5 編寫,fetch 也替換為 XMLHttpRequest。此外,由於程式碼量比較少,還可以通過 Webpack 的 inline-source-webpack-plugin,把構建後的 js 程式碼以行內指令碼的形式輸出到頁面上,減少一個 js 檔案請求。

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
</head>
<body>
  <div id="app"></div>
  <script inline inline-asset="main\.\w+\.js$" inline-asset-delete></script>
</body>
</html>

其他注意點

資源目錄檔案的過期時間

由於資源目錄檔案的路徑是固定的,所以該檔案要禁用 HTTP 的強快取,或者僅配置短時間的強快取。

否則,一旦使用者使用的瀏覽器長時間快取了該檔案,那麼在快取期間,不管更新了多少個版本,使用者訪問的仍然是快取下來的那個版本。

小小的加速

定製客戶畢竟是少數,大部分客戶用的仍然是標準的 SaaS 應用。也就是說,大部分情況下載入的是主分支的資源目錄檔案。因此,可以在入口頁提前載入這個資源:

<link href="https://cdn.my-app.net/sample/main/index-assets.json" rel="preload" as="fetch" />

關於預載入

link 標籤支援兩種方式的預載入:

preload 是提前載入,但是不阻塞 onload,主要用於預載入當前頁面會用到的資源;

prefetch 是閒時載入,主要用於載入將來可能會用到的資源。

以前文的 index.html 為例,app.2dd9bc59.css、vendors~app.f1dba939.js、app.f7eb55ca.js 這三個資源都在頁面中通過 link 或 script 標籤引用,所以會通過 preload 去提前載入。而其他資源則是將來可能會用到的資源(比如在某個時機才會動態 import 的資源),所以是通過 prefetch 閒時載入。

然而,在前文講到提取頁面 css 和 js 資源的時候,我們把 preload 的資源移除了,並且把 prefetch 的資源移到了末尾。為什麼要這麼做呢?我們從入口頁載入流程去分析這個問題。

如上圖所示:

  • 執行載入邏輯之後,頁面 onload 已經觸發,提前載入的時機早已過去,所以 preload 已經沒有意義。
  • 載入資源目錄檔案之後,載入 css、js 資源之前,頁面沒有其他的載入任務,已經處在空閒狀態。如果此時把 prefetch 的 link 元素插入到頁面中,瀏覽器馬上就會載入這部分資源。因此,在資源列表中,prefetch 的資源要往後放,讓那些應用初始化所需的資源可以被優先載入進來。

總結

總地來說,本文所述的方案有以下優勢:

  • 輕量化,無需依賴第三方庫或框架。
  • 無需改動應用的邏輯,而是在進入應用之前增加了一層入口頁,侵入性低。
  • 適配性廣,無需變更應用的技術棧。

然而,也具備一定的侷限性:

  • 只適用於前後端分離的應用,並且 html 檔案中不能承載任何功能。
  • 入口頁的三個步驟——執行載入邏輯、載入資源目錄檔案、載入資源,是序列執行的,頁面的白屏時間會增加。有條件的情況下,可以把前兩步放到後端去執行。
  • 未考慮後端的多分支管理方案。