乐趣区

关于前端工程化:在-Web-应用的运行时实现多分支并存和切换

背景

一般来说,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 文件中不能承载任何性能。
  • 入口页的三个步骤——执行加载逻辑、加载资源目录文件、加载资源,是串行执行的,页面的白屏工夫会减少。有条件的状况下,能够把前两步放到后端去执行。
  • 未思考后端的多分支治理计划。
退出移动版