微前端解决方案

Posted 记得要微笑

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了微前端解决方案相关的知识,希望对你有一定的参考价值。

前言

随着技术的发展,前端应用承载的内容也日益复杂,基于此而产生的各种问题也应运而生,从MPAMulti-Page Application,多页应用)到SPA(Single-Page Application,单页应用),虽然解决了切换体验的延迟问题,但也带来了首次加载时间长,以及工程爆炸增长后带来的巨石应用(Monolithic)问题;对于MPA来说,其部署简单,各应用之间天然硬隔离,并且具备技术栈无关、独立开发、独立部署等特点。要是能够将这两方的特点结合起来,会不会给用户和开发带来更好的用户体验?至此,在借鉴了微服务理念下,微前端便应运而生。

目前社区有很多关于微前端架构的介绍,但大多停留在概念介绍的阶段。而本文会就某一个具体的类型场景,着重介绍微前端架构可以带来什么价值以及具体实践过程中需要关注的技术决策,并辅以具体代码,从而能真正意义上帮助你构建一个生产可用的微前端架构系统。

什么是微前端?

微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将单页面前端应用由单一的单体应用转变为把多个小型前端应用聚合为一的应用。各个前端应用还可以独立开发、独立部署。

微前端的价值

微前端架构具备以下几个核心价值:

  • 技术栈无关 主框架不限制接入应用的技术栈,子应用具备完全自主权
  • 独立开发、独立部署 子应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
  • 独立运行时 每个子应用之间状态隔离,运行时状态不共享

微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用(Frontend Monolith)后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。

针对中后台应用的解决方案

中后台应用由于其应用生命周期长(动辄 3+ 年)等特点,最后演变成一个巨石应用的概率往往高于其他类型的 web 应用。这主要带来了技术栈落后、编译部署慢两个问题。而从技术实现角度,微前端架构解决方案大概分为以下几类场景:

  • 前端容器化:iframe能有效地将另一个网页/单页面应用嵌入到当前页面中,两个页面间的CSSjavascript是相互隔离的。iframe相当于创建了一个全新的独立的宿主环境,类似于沙箱隔离,它意味着前端应用之间可以相互独立运行。如果我们做一个应用平台,会在系统中集成第三方系统,或多个不同部门团队下的系统,将iframe作为容器来容纳其他前端应用,显然这依然是一个非常靠谱的方案。
  • 微组件:借助于Web Components技术,开发者可以创建可重用的定制元素,来构建跨框架的前端应用。通常使用 Web Components来做子应用封装,子应用更像是一个业务组件而不是应用。真正在项目上使用Web Components技术,离现在的我们还有些距离,可是结合Web Components来构建前端应用,是一种面向未来演进的架构。
  • 微应用:通过软件工程的方式,在部署构建环境中,把多个独立的应用组合成一个单体应用。
  • 微模块:开发一个新的构建系统,将部分业务功能构建成一个独立的chunk代码,使用时只需要远程加载即可。

微前端架构

当下微前端主要采用的是组合式应用路由方案,该方案的核心是“主从”思想,即包括一个基座(MainApp)应用和若干个微(MicroApp)应用,基座应用大多数是一个前端SPA项目,主要负责应用注册,路由映射,消息下发等,而微应用是独立前端项目,这些项目不限于采用React,Vue,Angular或者JQuery开发,每个微应用注册到基座应用中,由基座进行管理,但是如果脱离基座也是可以单独访问,基本的流程如下图所示:

当整个微前端框架运行之后,给用户的体验就是类似下图所示:

简单描述下就是基座应用中有一些菜单项,点击每个菜单项可以展示对应的微应用,这些应用的切换是纯前端无感知的,很好的借鉴了SPA无刷新的特点。

微前端架构实践中的问题

可以发现,微前端架构的优势,正是 MPA SPA 架构优势的合集。即保证应用具备独立开发权的同时,又有将它们整合到一起保证产品完整的流程体验的能力。

Stitching layer 作为主框架的核心成员,充当调度者的角色,由它来决定在不同的条件下激活不同的子应用。因此主框架的定位则仅仅是:导航路由 + 资源加载框架。

而具体要实现这样一套架构,我们需要解决以下几个技术问题:

路由系统及 Future State

我们在一个实现了微前端内核的产品中,正常访问一个子应用的页面时,可能会有这样一个链路:

此时浏览器的地址可能是 https://app.alipay.com/subApp/123/detail,想象一下,此时我们手动刷新一下浏览器,会发生什么情况?

由于我们的子应用都是 lazy load 的,当浏览器重新刷新时,主框架的资源会被重新加载,同时异步 load 子应用的静态资源,由于此时主应用的路由系统已经激活,但子应用的资源可能还没有完全加载完毕,从而导致路由注册表里发现没有能匹配子应用 /subApp/123/detail 的规则,这时候就会导致跳 NotFound 页或者直接路由报错。

这个问题在所有 lazy load 方式加载子应用的方案中都会碰到,早些年前 angularjs 社区把这个问题统一称之为 Future State。

解决的思路也很简单,我们需要设计这样一套路由机制:

主框架配置子应用的路由为 subApp: { url: \'/subApp/**\', entry: \'./subApp.js\' },则当浏览器的地址为 /subApp/abc 时,框架需要先加载 entry 资源,待 entry 资源加载完毕,确保子应用的路由系统注册进主框架之后后,再去由子应用的路由系统接管 url change 事件。同时在子应用路由切出时,主框架需要触发相应的 destroy 事件,子应用在监听到该事件时,调用自己的卸载方法卸载应用,如 React 场景下 destroy = () => ReactDOM.unmountAtNode(container)

要实现这样一套机制,我们可以自己去劫持 url change 事件从而实现自己的路由系统,也可以基于社区已有的 ui router library,尤其是 react-routerv4 之后实现了 Dynamic Routing 能力,我们只需要复写一部分路由发现的逻辑即可。这里我们推荐直接选择社区比较完善的相关实践 single-spa。

App Entry

解决了路由问题后,主框架与子应用集成的方式,也会成为一个需要重点关注的技术决策。

子应用载入方式

Monorepo

使用single-spa的最简单方法是拥有一个包含所有代码的仓库。通常,您只有一个package.json,一个的webpack配置,产生一个包,它在一个html文件中通过``标签引用。

NPM包

创建一个父应用,npm安装每个single-spa应用。每个子应用在一个单独的代码仓库中,负责每次更新时发布一个新版本。当single-spa应用发生更改时,根应用程序应该重新安装、重新构建和重新部署。

动态加载模块

子应用自己构建打包,主应用运行时动态加载子应用资源。

方案优点缺点
Monorepo1、最容易部署
2、单一版本控制
1、对于每个单独的项目来说,一个Webpack配置和package.json意味着的灵活性和自由度不足。
2、当你的项目越来越大时,打包速度越来越慢。
3、构建和部署都是捆绑在一起的,这要求固定的发版计划,而不能临时发布
NPM1、npm安装对于开发中更熟悉,易于搭建
2、独立的npm包意味着,每个应用在发布到npm仓库之前可以分别打包
1、父应用必须重新安装子应用来重新构建或部署
动态加载模块主应用与子应用之间完全解耦,子应用可以采用任何技术栈会多出一些运行时的复杂度和overhead

很显然,要实现真正的技术栈无关跟独立部署两个核心目标,大部分场景下我们需要使用运行时加载子应用这种方案。

JS Entry vs HTML Entry

在确定了运行时载入的方案后,另一个需要决策的点是,我们需要子应用提供什么形式的资源作为渲染入口?

JS Entry 的方式通常是子应用将资源打成一个 entry script,比如 single-spa 的 example 中的方式。但这个方案的限制也颇多,如要求子应用的所有资源打包到一个 js bundle 里,包括 css、图片等资源。除了打出来的包可能体积庞大之外的问题之外,资源的并行加载等特性也无法利用上。

HTML Entry 则更加灵活,直接将子应用打出来 HTML 作为入口,主框架可以通过 fetch html 的方式获取子应用的静态资源,同时将 HTML document 作为子节点塞到主框架的容器中。这样不仅可以极大的减少主应用的接入成本,子应用的开发方式及打包方式基本上也不需要调整,而且可以天然的解决子应用之间样式隔离的问题(后面提到)。想象一下这样一个场景:

<!-- 子应用 index.html -->
<script src="//unpkg/antd.min.js"></script>
<body>
  <main id="root"></main>
</body>
// 子应用入口
ReactDOM.render(<App/>, document.getElementById(\'root\'))

如果是 JS Entry 方案,主框架需要在子应用加载之前构建好相应的容器节点(比如这里的 "#root" 节点),不然子应用加载时会因为找不到 container 报错。但问题在于,主应用并不能保证子应用使用的容器节点为某一特定标记元素。而 HTML Entry 的方案则天然能解决这一问题,保留子应用完整的环境上下文,从而确保子应用有良好的开发体验。

HTML Entry 方案下,主框架注册子应用的方式则变成:

framework.registerApp(\'subApp1\', { entry: \'//abc.alipay.com/index.html\'})

本质上这里 HTML 充当的是应用静态资源表的角色,在某些场景下,我们也可以将 HTML Entry 的方案优化成 Config Entry,从而减少一次请求,如:

framework.registerApp(\'subApp1\', { html: \'\', scripts: [\'//abc.alipay.com/index.js\'], css: [\'//abc.alipay.com/index.css\']})

总结一下:

获取子应用资源清单

html 解析

将子应用打出来 HTML 作为入口,qiankun 会去通过 fetch 获取到子应用的 html 字符串(这就是为什么需要子应用资源允许跨域),拿到 html 字符串后,会调用 processTpl 方法通过一大堆正则去匹配获取 html 中对应的 js(内联、外联)、css(内联、外联)、注释、入口脚本 entry 等等。

asset-manifest.json

构建配置使用 stats-webpack-plugin 插件,生成一个资源清单manifest.json文件,用create-react-app搭建的react项目中webpack默认使用webpack-manifest-plugin生成资源清单。然后主应用ajax异步请求manifest.json

webpack-manifest-plugin插件生成的资源清单asset-manifest.json

{
  "files": {
    "main.js": "/index.js",
    "main.js.map": "/index.js.map",
    "static/js/1.97da22d3.chunk.js": "/static/js/1.97da22d3.chunk.js",
    "static/js/1.97da22d3.chunk.js.map": "/static/js/1.97da22d3.chunk.js.map",
    "static/css/2.8e475c3e.chunk.css": "/static/css/2.8e475c3e.chunk.css",
    "static/js/2.67d7628e.chunk.js": "/static/js/2.67d7628e.chunk.js",
    "static/css/2.8e475c3e.chunk.css.map": "/static/css/2.8e475c3e.chunk.css.map",
    "static/js/2.67d7628e.chunk.js.map": "/static/js/2.67d7628e.chunk.js.map",
    "static/css/3.5b52ba8f.chunk.css": "/static/css/3.5b52ba8f.chunk.css",
    "static/js/3.0e198e04.chunk.js": "/static/js/3.0e198e04.chunk.js",
    "static/css/3.5b52ba8f.chunk.css.map": "/static/css/3.5b52ba8f.chunk.css.map",
    "static/js/3.0e198e04.chunk.js.map": "/static/js/3.0e198e04.chunk.js.map",
    "index.html": "/index.html"
  },
  "entrypoints": [
    "index.js"
  ]
}

部署时生成importmap

gitlab或者文件存储服务器上创建importmap.json(配置文件),子应用部署后将项目-入口文件的映射关系写入importmap.json

// importmap.json
{
  imports: {
    \'icec-cloud-inquiry-mall-react\': \'http://localhost:8234/index.js\',
    \'icec-cloud-product-seller-react\': \'http://localhost:8235/index.js\'
  }
}

JS 模块的动态加载

微前端架构下,我们需要获取到子应用暴露出的一些钩子引用,如 bootstrapmountunmount 等(参考 single-spa),从而能对接入应用有一个完整的生命周期控制。而由于子应用通常又有集成部署、独立部署两种模式同时支持的需求,使得我们只能选择 umd 这种兼容性的模块格式打包我们的子应用。如何在浏览器运行时获取远程脚本中导出的模块引用也是一个需要解决的问题。

通常我们第一反应的解法,也是最简单的解法就是与子应用与主框架之间约定好一个全局变量,把导出的钩子引用挂载到这个全局变量上,然后主应用从这里面取生命周期函数。

这个方案很好用,但是最大的问题是,主应用与子应用之间存在一种强约定的打包协议。那我们是否能找出一种松耦合的解决方案呢?

目前正经的微前端方案主要是两种类型:

  • 一种是以蚂蚁金服 qiankun 为代表的工程之间技术栈无关型。
  • 另一种是以美团外卖为代表的工程之间技术栈统一型。

对于技术栈无关型来说,动态加载子工程主要是让子工程自己将内容渲染到某个 DOM 节点,因而动态加载的目的主要是执行子工程的代码,另外是需要拿到子工程声明的一些生命周期钩子;而技术栈统一型的目标则是要直接拿到子工程输出的组件等内容,将其动态嵌入到主工程内完成解析。

SystemJS

拿到子应用构建资源清单asset-manifest.json后,转化为importmap动态注入html中(在SystemJS@6.4.0才支持),然后使用System.import加载

字节跳动:new Function()

先来看一下字节跳动的实现,他们的方案是: “子模块(Modules)就是一个个的 CMD 包,我用 new Function 来包起来。” 简单的两句话,这应该是说用 fetch 或者其他请求库直接拿到作为 cmd 包的子工程内容,然后用 new Function 将子模块作为 function 的函数体来执行,传入自定义的 define 等参数来执行模块并拿到模块的输出。new Function 的用法如下,将子模块内容作为函数文本,跟 eval 是类似的,但使用起来会清晰一些:

let sum = new Function(\'a\', \'b\', \'return a + b\');

alert( sum(1, 2) ); // 3

但是这里本可以跟 requirejs 一样全局定义好 define 等全局变量,然后用 script 标签直接引用子工程自然加载执行,为什么要用 fetch + new Function 呢?可能是因为全局的 define 方法不方便在组件方法内部动态使用吧。

蚂蚁金服:eval()

再来看一下典型的技术栈无关型方案蚂蚁金服 qiankun 的实现方式,相关的代码都在它使用的 import-html-entry 仓库中,同样是通过 fetch 等请求库,但拿到的是作为 umd 包的子工程内容,并没有将子模块作为 amd 或者 cmd 使用,而是直接 eval 执行,挂载了 windoweval 执行时更改了绑定的 window 对象,这样做主要是通过 Proxy 拦截子工程对 window 全局变量的更改,做自定义的隔离处理。

qiankun 推荐的子应用 webpack 配置:

const packageName = require(\'./package.json\').name;

module.exports = {
  output: {
    library: `${packageName}-[name]`,
    libraryTarget: \'umd\',
    jsonpFunction: `webpackJsonp_${packageName}`,
  },
};

美团外卖:jsonp

接下来我们看一下技术栈统一型的美团外卖方案中模块的动态加载方法,他们的方案介绍中对于模块的加载方式没有细讲,但是从贴出的代码里可以看到 loadAsyncSubappsubappRoutes,也提到了触发 jsonp 钩子window.wmadSubapp,这表示他们的方案是通过 jsonp 实现的。可以设置子工程 webpack output libraryTarget jsonp,这样配置的的打包产物在加载时会执行全局的 jsonp 方法,传入主模块的 export 值作为参数。参考 webpack 文档 other-targets。亲测可行:

子工程 webpack config

output: {
  library: `registerSubApp`,
  libraryTarget: \'jsonp\',
}

主模块

export default App

webpack 产物

父工程

window.registerSubApp = function (lib) {
  ReactDOM.render(
    React.createElement(lib.default),
    document.getElementById(\'root\')
  )
}
// lib = {default: App}

这样父工程就可以直接拿到子工程的组件,进而可以将组件动态整合到主工程中。可以参考他们文章中介绍的结合 react-router 做的动态路由解析。

Webpack module federation

由于目前前端工程的主要打包方案是 webpack,微前端的很多动态加载方案都需要借助 webpack 的能力,后来自然就有人想到让 webpack 更好更方便地支持不同工程之间构建产物的互相加载,这就是 webpack module federation,使用方式可能是:

new ModuleFederationPlugin({
    name: \'app_two\',
    library: { type: \'global\', name: \'app_a\' },
    remotes: {
      app_one: \'app_one\',
      app_three: \'app_three\'
    },
    exposes: {
       AppContainer: \'./src/App\'
    },
    shared: [\'react\', \'react-dom\', \'relay-runtime\']
})
----
import(\'app_one/AppContainer\')

总的来说,JS 模块的动态加载可以分为两个阶段,加载阶段和执行阶段。按照加载方式分,有 script 标签加载和 fetch 请求两种方式。在执行阶段, script 标签加载一般配合全局的 jsonp 函数来做解析,函数具体做什么就看约定方式了,比如 requirejs 中的 define 方法和 webpack 动态加载时的 webpackJsonpCallback。而 fetch 请求方式拿到模块内容之后,则需要用 eval 或者 new Function 方法进行包装控制解析。

两种方式基本上都能达到效果,前一种方式的加载方法更简单,后一种方式对解析的控制可以更精细。

应用沙箱

微前端架构方案中有两个非常关键的问题,有没有解决这两个问题将直接标志你的方案是否真的生产可用。比较遗憾的是此前社区在这个问题上的处理都会不约而同选择”绕道“的方式,比如通过主子应用之间的一些默认约定去规避冲突。而今天我们会尝试从纯技术角度,更智能的解决应用之间可能冲突的问题。

纵观各类技术方案,有一个大前提决定了这个沙箱如何做:最终微应用是单实例 or 多实例存在宿主应用中。这个直接决定了这个沙箱的复杂度和技术方案。

  • 单实例:同一个时刻只有一个微应用实例存在,此刻浏览器所有浏览器资源都是这个应用独占的,方案要解决的很大程度是应用切换的时候的清理和现场恢复。比较轻量,实现起来也简单。
  • 多实例:资源不是应用独占,就要解决资源共享的情况,比如路由,样式,全局变量读写,DOM。可能需要考虑的情况比较多,实现较为复杂。

JS 隔离

如何确保各个子应用之间的全局变量不会互相干扰,从而保证每个子应用之间的软隔离?

这个问题比样式隔离的问题更棘手,社区的普遍玩法是给一些全局副作用加各种前缀从而避免冲突。但其实我们都明白,这种通过团队间的”口头“约定的方式往往低效且易碎,所有依赖人为约束的方案都很难避免由于人的疏忽导致的线上 bug。那么我们是否有可能打造出一个好用的且完全无约束的 JS 隔离方案呢?

环境快照

针对 JS 隔离的问题,我们独创了一个运行时的 JS 沙箱。简单画了个架构图:

即在应用的 bootstrapmount 两个生命周期开始之前分别给全局状态打下快照,然后当应用切出/卸载时,将状态回滚至 bootstrap 开始之前的阶段,确保应用对全局状态的污染全部清零。而当应用二次进入时则再恢复至 mount 前的状态的,从而确保应用在 remount 时拥有跟第一次 mount 时一致的全局上下文。

快照可以理解为暂存mount前的window对象,mount后会产生一个新的window对象,当umount后且回到暂存的window对象

当然沙箱里做的事情还远不止这些,其他的还包括一些对全局事件监听的劫持等,以确保应用在切出之后,对全局事件的监听能得到完整的卸载,同时也会在 remount 时重新监听这些全局事件,从而模拟出与应用独立运行时一致的沙箱环境。

初始加载主应用的脚本时可能全局监听了某些事件,比如window.resize,当umount后且回到暂存的window对象需要重新监听一边

示例代码:

class DiffSandbox {
  constructor(name) {
    this.name = name;
    this.modifyMap = {}; // 存放修改的属性
    this.windowSnapshot = {};
  }

  active() {
    // 缓存active状态的沙箱
    this.windowSnapshot = {};
    for (const item in window) {
      this.windowSnapshot[item] = window[item];
    }
    Object.keys(this.modifyMap).forEach(p => {
      window[p] = this.modifyMap[p];
    })
  }

  inactive() {
    for (const item in window) {
      if (this.windowSnapshot[item] !== window[item]) {
        // 记录变更
        this.modifyMap[item] = window[item];
        // 还原window
        window[item] = this.windowSnapshot[item];
      }
    }
  }
}

const diffSandbox = new DiffSandbox(\'diff沙箱\');
diffSandbox.active();  // 激活沙箱
window.a = \'1\'
console.log(\'开启沙箱:\',window.a);
diffSandbox.inactive(); //失活沙箱
console.log(\'失活沙箱:\', window.a);
diffSandbox.active();   // 重新激活
console.log(\'再次激活\', window.a);

这种方式也无法支持多实例,因为运行期间所有的属性都是保存在window

单实例场景下,如果子应用往浏览器原生对象中注入的属性和方法不与主应用中本有的属性和方法冲突,那么是可行的

模拟沙箱环境(proxy+闭包)

要实现沙箱,我们需要隔离掉浏览器的原生对象,但是如何隔离,建立一个沙箱环境呢?Node 中 有 vm 模块,来实现类似的能力,但是浏览器就不行了,但是我们可以利用闭包的能力、利用变量作用域去模拟一个沙箱环境,比如下面的代码:

function foo(window) {
  console.log(window.document);
}
foo({
    document: {};
});

比如这段代码的输出一定是 {},而不是原生浏览器的 document

所以 ConsoleOS(面向阿里云管体系的微前端方案) 实现了一个 Wepback 的插件,在应用代码构建的时候给子应用代码加上一层 wrap 代码,创建一个闭包,把需要隔离的浏览器原生对象变成从下面函数闭包中获取,从而我们可以在应用加载的时候,传入模拟的 window、document 之类的对象。

// 打包代码
__CONSOLE_OS_GLOBAL_HOOK__(id, function (require, module, exports, {window, document, location, history}) {
  /* 打包代码 */
})
function __CONSOLE_OS_GLOBAL_HOOK__(id, entry) {
  entry(require, module, exports, {window, document, location, history})
}

当然也可以不靠工程化的手段来实现,也可以通过请求脚本,然后在运行时拼接这段代码,然后 eval 或者 new Function 来达到相同的目的。

沙箱隔离能力有了,剩下的问题就是如何实现这一堆浏览器的原生对象了。最开始的想法是我们根据 ECMA 的规范实现(现在仍然有类似的想法),但是发现成本太高。不过在我们各种实验之后,发现了一个很“取巧”的做法,我们可以 new iframe 对象,把里面的原生浏览器对象通过 contentWindow 取出来,因为这些对象天然隔离,就省去了自己实现的成本。

const iframe = document.createElement( \'iframe\', {src:\'about:blank\'});
document.body.appendChild(iframe);
const sandboxGlobal = iframe.contentWindow;

当然里面有很多的细节需要考量,比如:只有同域的 iframe 才能取出对应的的 contentWindow。所以需要提供一个宿主应用空的同域 URL 来作为这个 iframe 初始加载的 URL。当然根据 HTML 的规范,这个 URL 用了 about:blank 一定保证同域,也不会发生资源加载,但是会发生和这个 iframe 中关联的 history 不能被操作,这个时候路由的变换只能变成 hash 模式。

如下图所示,微前端除了有一个隔离的window环境外,其实还需要共享一些全局对象,这时候我们可以用代理去实现,Proxy 可以对目标对象的读取、函数调用等操作进行拦截,然后进行操作处理。它不直接操作对象,而是像代理模式,通过对象的代理对象进行操作,在进行这些操作时,可以添加一些需要的额外操作。我们取出对应的 iframe 中原生的对象之后,对特定需要隔离的对象生成对应的 Proxy,然后对一些属性获取和属性设置,做一些特定的设置,比如为了文档能够被加载在同一个 DOM 树上,对于 document,大部分的 DOM 操作的属性和方法还是直接用的宿主浏览器中的 document 的属性和方法。

class SandboxWindow {
    constructor(context, frameWindow) {
        return new Proxy(frameWindow, {
          set(target, name, value) {
              if (name in context) { // 优先使用共享对象
                  return context[name] = value;
              }
              target[name] = value;
              return target[name];
          },

          get(target, name) {
              if (name in context) { // 优先使用共享对象
                  return context[name];
              }

              if(typeof target[ name ] === \'function\' && /^[a-z]/.test( name )) {
                  return target[ name ].bind && target[ name ].bind( target );
              } 
              
              return target[name];
          }
        });
    }
}

// 需要全局共享的变量
const context = { document:window.document, history: window.history }

// 创建沙箱
const newSandboxWindow = new SandboxWindow(context, sandboxGlobal);

// 执行chunk时注入沙箱环境
((window) => {new Function(\'chunk code\')})(newSandboxWindow);

由于子应用有自己的沙箱环境,之前所有独占式的资源现在都变成了应用独享(尤其是 locationhistory),所以子应用也能同时被加载。并且对于一些变量,我们还能在 proxy 中设置一些访问权限的事情,从而限制子应用的能力,比如 Cookie LocalStoage 读写。

当这个 iframe 被移除时,写在 window 的变量和设置的一些 timeout 时间也会一并被移除(当然 DOM 事件需要沙箱记录,然后在宿主中移除)。
总结一下,我们的沙箱可以做到如下的特性:

如果看懂了上面关于原理的介绍可以看到其实沙箱实现包括两个层面:

  • 原生浏览器对象的模拟(Browser-VM)
  • 如何构建一个闭包环境

Browser-VM可以直接用起来,这部分完全是通用普适的。但是涉及到闭包构建的这部分,每个微前端体系不太一致,可能需要改造,比如:

import { createContext, removeContext } from \'@alicloud/console-os-browser-vm\';
const context = await createContext();
const run = window.eval(`
  (() => function({window, history, locaiton, document}) {
    window.test = 1;
  })()
`)
run(context);
console.log(context.window.test);
console.log(window.test);
// 操作虚拟化浏览器对象
context.history.pushState(null, null, \'/test\');
context.locaiton.hash = \'foo\'
// 销毁一个 context
await removeContext( context );

当然可以直接选择沙箱提供好的 evalScripts 方法:

import { evalScripts } from \'@alicloud/console-os-browser-vm\';
const context = evalScripts(\'window.test = 1;\')
console.log(window.test === undefined) // true

样式隔离

由于微前端场景下,不同技术栈的子应用会被集成到同一个运行时中,所以我们必须在框架层确保各个子应用之间不会出现样式互相干扰的问题。

Shadow DOM?

针对 "Isolated Styles" 这个问题,如果不考虑浏览器兼容性,通常第一个浮现到我们脑海里的方案会是 Web Components。基于 Web Components Shadow DOM 能力,我们可以将每个子应用包裹到一个 Shadow DOM 中,保证其运行时的样式的绝对隔离。

Shadow DOM 方案在工程实践中会碰到一个常见问题,比如我们这样去构建了一个在 Shadow DOM 里渲染的子应用:

const shadow = document.querySelector(\'#hostElement\').attachShadow({mode: \'open\'});
shadow.innerHTML = \'<sub-app>Here is some new text</sub-app><link rel="stylesheet" href="//unpkg.com/antd/antd.min.css">\';

由于子应用的样式作用域仅在shadow 元素下,那么一旦子应用中出现运行时越界跑到外面构建 DOM 的场景,必定会导致构建出来的 DOM 无法应用子应用的样式的情况。

比如 sub-app 里调用了 antd modal 组件,由于 modal 是动态挂载到 document.body 的,而由于 Shadow DOM 的特性antd 的样式只会在 shadow 这个作用域下生效,结果就是弹出框无法应用到 antd 的样式。解决的办法是把 antd 样式上浮一层,丢到主文档里,但这么做意味着子应用的样式直接泄露到主文档了。gg...

CSS Module or CSS Namespace?

社区通常的实践是通过约定 css 前缀的方式来避免样式冲突,即各个子应用使用特定的前缀来命名 class,或者直接基于 css module 方案写样式。对于一个全新的项目,这样当然是可行,但是通常微前端架构更多的目标是解决存量/遗产 应用的接入问题。很显然遗产应用通常是很难有动力做大幅改造的。

最主要的是,约定的方式有一个无法解决的问题,假如子应用中使用了三方的组件库,三方库在写入了大量的全局样式的同时又不支持定制化前缀?比如 a 应用引入了 antd 2.x,而 b 应用引入了 antd 3.x,两个版本的 antd 都写入了全局的 .menu class,但又彼此不兼容怎么办?

结合CSS ModuleCSS Namespace可以处理上述问题

Dynamic Stylesheet !

解决方案其实很简单,我们只需要在应用切出/卸载后,同时卸载掉其样式表即可,原理是浏览器会对所有的样式表的插入、移除做整个 CSSOM 的重构,从而达到 插入、卸载 样式的目的。这样即能保证,在一个时间点里,只有一个应用的样式表是生效的。

上文提到的 HTML Entry 方案则天生具备样式隔离的特性,因为应用卸载后会直接移除去 HTML 结构,从而自动移除了其样式表。

比如 HTML Entry 模式下,子应用加载完成的后的 DOM 结构可能长这样:

<html>
  <body>
    <main id="subApp">
      // 子应用完整的 html 结构
      <link rel="stylesheet" href="//alipay.com/subapp.css">
      <div id="root">....</div>
    </main>
  </body>
</html>

当子应用被替换或卸载时,subApp 节点的 innerHTML 也会被复写,//alipay.com/subapp.css 也就自然被移除样式也随之卸载了。

局限性一是对于站点框架本身或其部件(header/menu/footer)与当前运行的微应用间仍存在样式冲突的可能性,二是没有办法支持多个微应用同时运行显示的情况。

qiankun VS single-spa

上述描述的问题是qiankun框架在蚂蚁落地产生的,qiankun是基于single-spa开发的,那它与single-spa又有哪些区别呢?

qiankun

在乾坤的角度,微前端就是“微应用加载器”,它主要解决的是:如何安全、快速的把多个分散的项目集中起来的问题,这从乾坤自身提点便可看出:

所有这些特性都是服务于“微应用加载器”这个定位。

single-spa

在single-spa的角度,微前端就是“微模块加载器”,它主要解决的是:如何实现前端的“微服务化”,从而让应用、组件、逻辑都成为可共享的微服务,这从single-spa关于微前端的概述中可以看出:

single-spa看来微前端有三种类型:微应用、微组件、微模块,实际上single-spa要求它们都以SystemJS的形式打包,换句话说它们本质上都是微模块。

SystemJS是一个运行时加载模块的工具,是现阶段下(浏览器尚未正式支持importMap)原生ES Module的完全替代品。SystemJS动态加载的模块必须是SystemJS模块或者UMD模块。

qiankun与single-spa区别?

乾坤基于single-spa,加强了微应用集成能力,却抛弃了微模块的能力。所以,它们的区别就是微服务的粒度,乾坤的所能服务的粒度是应用级别,而single-spa则是模块级别。它们都能将前端进行拆分,只是拆分的粒度不同罢了。

  1. 微应用加载器:“微”的粒度是应用,也就是HTML,它只能做到应用级别的分享
  2. 微模块加载器:“微”的粒度是模块,也就是JS模块,它能做到模块级别的分享

为什么要这么做呢?我们要想清楚这两个框架出现的背景:

qiankun:阿里内部有大量年久失修的项目,业务侧急需工具去把他们快速、安全的集成到一起。在这个角度,乾坤根本没有做模块联邦的需求,它的需求仅仅是如何快速、安全的把项目集成起来。所以乾坤是想做一个微前端工具。

single-spa:学习后端的微服务,实现前端的微服务化,让应用、组件以及逻辑都成为可共享的微服务,实现真正意义上的微前端。所以single-spa是想做一个game-changer

这里我还整理了一个图方便理解:

总结

要实现微前端,模块加载、样式和脚本沙箱隔离都是绕不过去的坎,本文从网上收集了一些资源,并结合自己的理解整理出一些可行性方案,如有不严谨之处,还望大家指正~

参考文章

可能是你见过最完善的微前端解决方案

架构设计:微前端架构

几种微前端方案探究

适用于既有大型MPA项目的“微前端”方案

再谈微前端

基于Single-SPA的微前端架构

Single-Spa + Vue Cli 微前端落地指南 + 视频 (项目隔离远程加载,自动引入)

阿里云开放平台微前端方案的沙箱实现

浅谈前端JS沙箱实现的几种方式

微前端时代如何做 JS 模块的动态加载

微前端-最容易看懂的微前端知识

以上是关于微前端解决方案的主要内容,如果未能解决你的问题,请参考以下文章

如何使用 Keycloak 保护 Angular 8 前端和使用网关、eureka 的 Java Spring Cloud 微服务后端

可能是你见过的最完善的微前端解决方案

前端开发常用代码片段(中篇)

前端开发常用js代码片段

前端单页应用微服务化解决方案2 - Single-SPA

前端防扒代码片段