Skip to content

实现方案选型

iframe

优点

  • 技术成熟(兼容性好)
  • 使用简单(直接一个src的url接入)
  • 天然支持沙箱隔离、独立运行
  • 现成的通信api(postmessage)

缺点

  • 加载、渲染、缓存由浏览器决定,性能不好且不可定制
  • 资源重复从头加载(多页面应用)

web component

TODO: 是什么,怎么做成微前端

优点

  • 利用shadow dom,关联进行控制
  • 支持slot、template插槽

缺点

  • 接入的子应用需要打包成web component
  • 生态不完善,技术过新
  • 兼容性不好

自研框架(从0写)

优点

  • 高度定制化,按需设计
  • 通信和沙箱隔离
  • 不从头刷新页面来加载

缺点

  • 技术实现难,自己实现通信、沙箱隔离等
  • 首次加载资源过大

自研微前端架构

  • 路由分发
  • 子应用共享依赖资源
  • 子应用接入主应用后,改动小就可以相互通信

整体系统架构图

技术栈

前端

  • 主应用vue3
  • 子应用vue2 vue3 react15 react16

后端

  • koa

前端各应用静态服务器自动部署

  • express

工具库

  • 路由匹配机制
  • 接入子应用机制
  • 各自生命周期
  • 应用通信机制
  • 沙箱隔离
  • 全局状态管理
  • 共享依赖

iframe通信

  • BroadcastChannel

BroadcastChannel 能够用于同源不同页面之间完成通信的功能。它与 window.postMessage 的区别就是,BroadcastChannel 只能用于同源的页面之间进行通信,而 window.postMessage 却可以用于任何的页面之间;BroadcastChannel 可以认为是 window.postMessage 的一个实例,它承担了 window.postMessage 的一个方面的功能。

js
const channel = new BroadcastChannel('channel-name');

channel.postMessage('some message');
channel.postMessage({ key: 'value' });

channel.onmessage = function(e) {
  const message = e.data;
};

channel.close();
  • SharedWorker API

Shared Worker 类似于 Web Workers,不过其会被来自同源的不同浏览上下文间共享,因此也可以用作消息的中转站。使用方式和作用可以类比localstore

js
// main.js
const worker = new SharedWorker('shared-worker.js');

worker.port.postMessage('some message');

worker.port.onmessage = function(e) {
  const message = e.data;
};

// shared-worker.js
const connections = [];

onconnect = function(e) {
  const port = e.ports[0];
  connections.push(port);
};

onmessage = function(e) {
  connections.forEach(function(connection) {
    if (connection !== port) {
      connection.postMessage(e.data);
    }
  });
};
  • Local Storage

localStorage 是常见的持久化同源存储机制,其会在内容变化时触发事件,也就可以用作同源界面的数据通信。

js
localStorage.setItem('key', 'value');

window.onstorage = function(e) {
  const message = e.newValue; // previous value at e.oldValue
};

Web Components && Shadow DOM

兼容性不好,移动端一般不敢直接用,而是编译成js资源包

Web Components 的目标是减少单页应用中隔离 HTML,CSS 与 JavaScript 的复杂度

  • Custom Elements,
  • Shadow DOM
  • Template Element
  • HTML Imports
  • Custom Properties

Shadow DOM 它允许在文档(document)渲染时插入一棵 DOM 元素子树,但是这棵子树不在主 DOM 树中

用纯html和js创建shadowDom

html
<html>
  <head></head>
  <body>
    <p id="hostElement"></p>
    <script>
      // 创建 shadow DOM
      var shadow = document.querySelector('#hostElement')
        .attachShadow({ mode: 'open' });
      // 给 shadow DOM 添加文字
      shadow.innerHTML = '<p>Here is some new text</p>';
      // 添加CSS,将文字变红
      shadow.innerHTML += '<style>p { color: red; }</style>';
    </script>
  </body>
</html>

用react创建shadowDom

js
import React from 'react';
import retargetEvents from 'react-shadow-dom-retarget-events';

class App extends React.Component {
  render() {
    return <div onClick={() => alert('I have been clicked')}>Click me</div>;
  }
}

const proto = Object.create(HTMLElement.prototype, {
  attachedCallback: {
    value() {
      const mountPoint = document.createElement('span');
      const shadowRoot = this.createShadowRoot();
      shadowRoot.appendChild(mountPoint);
      ReactDOM.render(<App />, mountPoint);
      retargetEvents(shadowRoot);
    }
  }
});

// 这是注册自定义标签,不是插入dom,实际插入还是要在html或jsk中写
document.registerElement('my-custom-element', { prototype: proto });

部署

指把打包后的前端静态资源放到服务器并配置好环境

以免混淆,以后打包工具的编译时称作构建时building

现代前端框架上线后的前端项目都是依靠构建时打包成一个体系的前端文件

好处是,较好地依赖管理,抽取公共模块,减少最终的包体大小,不过其最终的产出仍是单体应用,各个应用模块无法进行独立部署

另一种方式是:运行时runtime组合

  • 服务端整合(SSI):Tailor
  • 客户端整合:JSPM、SystemJS

运行时组合同时能提供按需加载的特性,优化首页的加载速度

不过运行时组合可能重复加载依赖项(通过浏览器缓存或 HTTP2 适度解决

最好是单例形式,否则容易污染

React 这样的声明式组件框架,天然就支持应用的组合,我们可以传入渲染锚点以进行应用组合,也可以将不同框架的应用封装为 Web Components。首先我们可以将 React 应用定义为自定义元素:

js
window.customElements.define(
  'react-app',
  class ReactApp extends HTMLElement {
    ...
    render() {
      render(<App title={this.title} />, this);
    }
    ...
  }
);

然后在前端中直接使用该自定义元素:

html
<react-app title="React Separate Running App" />

子应用部署层js包,在主应用根据路由加载,子应用部署,主应用加载就能拿到新的

主应用引用子应用的方式

js

子应用提供一个entry.js,由主应用加载

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

js
// 主应用 路由配置加载方法
import * as singleSpa from 'single-spa';

// import() 异步加载资源
singleSpa.registerApplication('app-1', () => import ('../app1/app1.js'), pathPrefix('/app1'));
singleSpa.registerApplication('app-2', () => import ('../app2/app2.js'), pathPrefix('/app2'));

singleSpa.start();

function pathPrefix(prefix) {
  return function(location) {
    return location.pathname.startsWith(`${prefix}`);
  }
}

子应用前端框架(entry.js)需要指定节点进行渲染,主应用就要提前写上这个节点。意味着介入一个新的子应用就需要重新部署主应用

html

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

实际上还是通过js,插入html

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

相对于js,会多一次加载html的步骤

js entry,路由配置加载到到入口js(就是一个完整的包)

html则会路由加载到html,dom插入容器,加载js(完整的包/html加载多个小包)

当然,这里的html并不是真的作为html使用,而是取出里面的需要挂载实例的节点和资源 <srcipt>

所以也可以不请求一次html,同样用用路由配置

js
framework.registerApp('subApp1', {
  html: '',
  scripts: ['//abc.alipay.com/index.js'],
  css: ['//abc.alipay.com/index.css']
})  // 这个对象就是html中取出的内容,这样配置,跟js entry其实没有区别,还是要部署主应用,当然可以改成配置中心处理

配置中心/接口的方式代理部署主应用

其实没省到加载html,所以加载html比起配置,更符合前端的习惯

比较 引入子应用方式比较

生命周期

要使用生命周期,子应用就必须是umd的模式

因为子应用通常又有集成部署、独立部署两种模式同时支持的需求

浏览器中可以使用模块化接收资源事件或变量的方式ESM、UMD

子应用与主应用约定好一个全局变量,把导出的钩子引用挂载到这个全局变量上,主应用从这里面取生命周期函数

这样需要手动让主子应用的生命周期变量保持一致

解决办法:

所有子应用共用window下的生命周期变量

umd 包格式中的 global export 方式获取子应用的导出一个子应用标识

大体的思路是通过给 window 变量打标记,记住每次最后添加的全局变量,这个变量一般就是应用 export 后挂载到 global 上的变量 实现方式可以参考 systemjs global import,这里不再赘述。

样式隔离

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

shadowDom

js
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的样式作用域仅在 shadow 元素下

比如子应用里调用了 antd modal 组件,由于 modal 是挂载到 document.body 的

结果就是modal组件没有样式,因为子应用里才有antd组件的样式,而子应用的容器是shadowDom,所以组件没有样式

  • 解决办法1是把 antd 样式上浮一层,丢到主应用里。。。
  • 解决办法2约定所有子应用不要用挂载到body的组件,而是用主应用提供的api式组件(即子应用调用主应用组件)

CSS Module/BEM?

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

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

动态样式表 Dynamic Stylesheet

解决子应用间的样式污染,那就只允许一个子应用的样式存在

只需要在应用切出/卸载后,同时卸载掉其样式表即可

原理是浏览器会对所有的样式表的插入/移除,做整个 CSSOM 的重构,从而达到 插入/卸载 样式的目的

即在一个时间点里,只有一个应用的样式表是生效的。

上文的 HTML Entry 方案则天生具备样式随子应用卸载而移除,因为当子应用被替换或卸载时,subApp 节点的 innerHTML 也会被复写,从而自动移除了其样式表。

当然,主子应用间的样式冲突,还是要靠css前缀人工约定好

js隔离

子应用也是经常需要往window下挂载东西的,如第三方库,子应用级别全局变量

一般还是人为约定全局变量命名带前缀

js沙箱

每个子应用都会有多个全局变量,这里称为全局变量表

全局变量表随着子应用卸载而清空,一次只存在一个子应用的全局变量表

在应用的 bootstrap 及 mount 两个生命周期开始之前分别给全局状态打下快照

加载子应用前的全局变量表(纯主应用的全局变量),被记录下来。

在子应用挂载自己的全局变量表之后,卸载的时候,恢复到之前的全局变量,并缓存该子应用的全局变量表,下一次可以恢复

当应用切出/卸载时,将状态回滚至 bootstrap 开始之前的阶段,确保应用对全局状态的污染全部清零

而当应用二次进入时则再恢复至 mount 前的状态的,从而确保应用在 remount 时拥有跟第一次 mount 时一致的全局上下文

当然沙箱里做的事情还远不止这些,其他的还包括

  • 对全局事件监听的劫持,确保应用在切出之后,对全局事件的监听能得到完整的卸载
  • 同时也会在 remount 时重新监听这些全局事件,从而模拟出与应用独立运行时一致的沙箱环境

不考虑子应用中的组件查到主应用中

即 完全隔离的子应用

参考资料