微前端

本文最后更新于:2022/01/03 , 星期一 , 21:48

目前前端存在的问题?

  1. 在目前所流行的主流框架 vue,react 等,他都是将属于一个单页面应用。在开发的过程中,随着业务的深入和复杂,将会带来逻辑定位问题、打包速度问题、部署上线问题。往往我们可能只是更改了一行 JS 代码,到最后发布的时候,整个项目却要整个重新打包编译发布。

  2. 公司可能之前的系统使用的是 JQ 或者其他框架进行开发,这个时候,我们想要追赶一下潮流。使用 react 或者 vue 进行开发。这个时候,我们就不得不对之前的项目使用新技术进行开发。

  3. 在如今的单页面应用里,所有的 JS 到最后都打包到一个Bundle.js文件里,这就会导致线上用户第一次进入的时长比较长,对于前端性能统计中的FP,FCP,TTI等一系列指标产生重大影响。虽然我们可以使用懒加载的形式去对代码进行拆分下载,但是依然会导致上述问题,因为懒加载是在路由发生变化的时候去加载的,此时此刻当你路由切换了,需要经过 DNS 解析,三次握手,然后传输,代码解析等等步骤,这其中也会耗费一些时间。

如何破局?

我们希望可以有这么一种技术或者架构:

  1. 它能够使各个子模块或者子系统进行隔离。这样我们在更新一个子模块的时候,我们只需要对这个子模块进行打包,发布上线。不会影响到其他模块。并且因为各个子系统之间相互隔离,项目就会拆分的轻量化,打包速度,前端性能等也会上去。并且因为各个子系统之前的相互隔离,这样就不会受限于技术栈的影响,你们各自系统只要能实现功能就行。

  2. 它能够使各个子系统进行数据共享。例如用户信息。

  3. 他能够对 JS,css 等进行相互隔离,防止出现污染问题。

微前端是什么?

微前端(Micro-Frontends)是一种类似于微服务的架构,它将微服务的理念应用于前端,即将 Web 应用由单一的单页面应用转变为多个小型前端应用聚合为一的应用。然后各个前端应用还可以独立运行、独立开发、独立部署。

微前端的方案

路由转发

当前的单页面应用的路由控制都是在前端进行。这就导致我们必须使用同一的技术栈,要不然react-dom-router他指挥不了 vue 路由,vue-router也指挥不了 react。这也会导致我们的项目必须在一个同一的项目里进行开发,因为跨项目的话,就算技术栈一样,A 也指挥不了 B 里面的路由跳转。所以我们可以直接将路由全都扔给后端来处理,访问不同的 url,返回不同的 html+js+css。如果要分享用户信息等,可以通过 cookie 等技术进行分享。因为每次路由匹配到的话,都会进行刷新,因此也防止了 JS,css 的污染问题

优点:

  • 简单,可快速配置。

缺点:

  • 每次跳转都相当于重新刷新了一次页面,不是页面内进行跳转。影响体验。

iframe

通过创建一个父程序,在父程序中监听路由的变化,卸载或加载相应的子程序 iframe。因每一个 iframe 就相当于一个单独的页面,所以 iframe 具有天然的 JS 和 css 隔离。在信息共享方面,我们可以使用 postMessage 或者 contentWindow 的方式进行。(我第一家公司的微前端就是这么搞的,因为是和腾讯合作的,腾讯负责做外面的壳子,我们做里面的内容,然后我们任何操作需要到外面都会用 postMessage 出去)

优点:

  • 实现起来简单,自带沙盒特性

缺点:

  • iframe 样式兼容问题。分别为功能性兼容性以及业务性兼容性的问题。可能会存在一些安全问题。postmessage 可以试出来。

  • 主应用劫持快捷键操作

  • 事件无法冒泡顶层,针对整个应用统一处理时效。

  • iframe 内元素会被限制在文档树中,视窗宽高限制问题

  • 无法共享基础库进一步减少包体积

  • 事件通信繁琐且限制多(https://blog.csdn.net/willspace/article/details/49003963)

web components

web components开发。将每个子应用采用 web components 进行开发。纯 web-components 相当于自定义了一个 html 标签,我们就可以在任何的框架中进行使用此标签。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template id="userInfo">
<div class="user-box">
<p class="user-name">byeL</p>
<p class="user-sex"></p>
</div>
</template>;
class UserInfo extends HTMLElement {
constructor() {
super();

var templateElem = document.getElementById('userInfo');
var content = templateElem.content.cloneNode(true);
this.appendChild(content);
}
}
window.customElements.define('user-info', UserCard);
1
2
3
4
5
6
7
8
9
10
11
12
13
// 直接在html中使用
<body>
<link rel="import" href="./UserInfo.js" />
</body>

// 在vue中使用 // a.vue // 需要在入口的main中引入userInfo
<template>
<user-info></user-info>
</template>
// 需要在入口的main中引入userInfo // 在react中使用 class HelloMessage extends
React.Component { render() { return
<div><user-info></user-info></div>
; } }

优点: 每个子应用拥有独立的 script 和 css,也可单独部署

缺点:需要对之前的子系统都要进行改造,并且通信方面较为复杂。

组合式应用路由分发。

每个子应用单独的打包,部署和运行。不过需要基于父应用进行路由管理。例如:有子应用 A 的路由是/testA,子应用 B 的路由是/testB,那么父应用在监听到/testA 的时候,如果此时处于/testB,那么首先会进行一个子应用 B 的卸载。完成之后,在去加载子应用 A。

优点:纯前端改造,相比于路由式,无刷新,体验感良好。

缺点:需要解决样式冲突,JS 污染问题,通信技术等。

目前主流方案–组合式应用路由分发。

css 冲突解决方案

  1. 类似于 vue 的 scoped。在打包的时候,对 css 选择器加上响应的属性,属性的 key 值是一些不重复的 hash 值,然后在选择的时候,使用属性选择器进行选择。

  2. 可以自定义前缀。在开发子模块之前,需要确定一个全局唯一的 css 前缀,然后在书写的过程中同一添加此前缀,或在根 root 上添加此前缀,使用 less 或 sass 作用域嵌套即可解。例如:

1
2
3
4
5
6
7
8
9
10
<div class="rootA">
<span class="rootA-span"></span>
</div>
<style>
.root {
.rootA-span {
// 书写你的css
}
}
</style>

js 污染解决方案

js 污染解决方案主要思路:沙盒,其实就是每个子应用都有自己的运行环境。

  1. 缓存法:当我们的子页面加载到父类的基座中的时候,我们可以生成一个 map。在页面渲染之前,我们先把当前的 window 上的变量等都存储在这个 map 中。当页面卸载的时候,我们在遍历这个 map,将其数据在替换回去。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Sandbox {
constructor() {
this.cacheMy = {}; // 存放修改的属性
this.cacheBeforeWindow = {};
}
showPage() {
this.cacheBeforeWindow = {};
for (const item in window) {
this.cacheBeforeWindow[item] = window[item];
}

Object.keys(this.cacheMy).forEach((p) => {
window[p] = this.cacheMy[p];
});
}

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

const diffSandbox = new Sandbox();
// 模拟页面激活
diffSandbox.showPage(); // 激活沙箱
window.info = '我是子应用';
console.log('页面激活,子应用对应的值', window.info);
// 模拟页面卸载
diffSandbox.hidePage();
// 模拟页面激活
console.log('页面卸载后,子应用的对应的值', window.info);
diffSandbox.showPage(); // 重新激活
console.log('页面激活,子应用对应的值', window.info);
  1. 使用代理的形式proxy。监听 get 和 set 方法,针对当前路由进行 window 的属性或方法的存取
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const windowMap = new Map();
const reseatWindow = {};

let routerUrl = '';
const handler = {
get: function (obj, prop) {
const tempWindow = windowMap.get(routerUrl);
console.log(windowMap, routerUrl);
return tempWindow[prop];
},
set: function (obj, prop, value) {
if (!windowMap.has(routerUrl)) {
windowMap.set(routerUrl, JSON.parse(JSON.stringify(reseatWindow)));
}
const tempWindow = windowMap.get(routerUrl);
tempWindow[prop] = value;
// console.log(obj, prop, value);
},
};

let proxyWindow = new Proxy(reseatWindow, handler);
// 首先是父类的a属性.
proxyWindow.a = '我是父类的a属性的值';

// 改变路由到子类
routerUrl = 'routeA';
proxyWindow.a = '我是routerA的a属性的值';

// 改变路由到父类
routerUrl = '';
console.log(proxyWindow.a);

// 改变路由到子类
routerUrl = 'routeA';
console.log(proxyWindow.a);

使用single-SPA搭建微前端

准备工作

首先先用npx create-react-app xxx分别创建app1,app2,main。

然后在main的根目录执行npm install --save react-router-dom single-spa,

接着在app1 和 app2下执行npm run eject

修改一下webpack的配置,参考资料

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// script/start.js
// 修改默认端口,app1 app2 main不能相同
const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3001;
// 修改webpack配置
// config/webpack.config.js
// 删除所有关于HtmlWebpackPlugin
const HtmlWebpackPlugin = require('html-webpack-plugin');
// 删除optimization。
output:{
// output下新增
library:"app1",
libraryTarget:"umd",
}

修改基座

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// scr/app.js
import { registerApplication, start} from "single-spa"
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
<ul>
// 用react-router来控制一下路由
<Link to="/app1">app1</Link>
<Link to="/app2">app2</Link>
</ul>
</p>
</header>
// 子应用要挂载的容器
<div id="container"></div>
</div>
);
}
export const createScript = (url) => {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = url;
script.onload = resolve;
script.onerror = reject;
const firstScript = document.getElementsByTagName("script")[0];
firstScript.parentNode.appendChild(script, firstScript);
});
};

const loadApp = (url,globalVal)=>{
return async ()=>{
await createScript(url +"/static/js/bundle.js");
return window[globalVal];
}
}

// 子应用配置
const apps = [
{
// 子应用名称
name:"app1",
// 子应用加载函数
app:loadApp("http://localhost:3001","app1"),
// 当路由满足条件时,挂载子应用
activeWhen:location=>location.pathname.startsWith("/app1"),
// 传给子应用的
customProps:{}
},
{
name: "app2",
app: loadApp("http://localhost:3002", "app2"),
activeWhen: location => location.pathname.startsWith("/app2"),
customProps: {}
}
]

// 注册子应用
for(let i = apps.length-1;i>=0;i--){
registerApplication(apps[i]);
}
start();

修改子应用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// index.js
import singleSpaReact from 'single-spa-react';
import App from './App'
import './index.css';

const reactLifecycles = singleSpaReact({
React,
ReactDOM,
// 这里一定要找到基座的容器
domElementGetter: () => document.querySelector('#container'),
// 要挂载的组件
rootComponent: App
});

export const bootstrap =(props)=>{
console.log("bootstrap")
return reactLifecycles.bootstrap(()=>{})
}
export const mount = (props)=>{
console.log("mount");
return reactLifecycles.mount(()=>{})
}
export const unmount = (props)=>{
console.log("unmount");
return reactLifecycles.unmount(()=>{})
}

然后在main 和app1 下分别运行npm run start,在main里面点一下看看是不是成了。
这样简单的一个微前端架构就搞定了。

singleSPA执行过程

参考代码

2021/12/23/singleSPA工作流程及子应用生命周期

具体源码可以自行查看。大概看了一下挺容易理解的。

qiankun

qiankun其实就是基于singleSPA的一套微前端实现库。如果singleSPA了解了,搞这个其实也挺容易的。

其实看他的快速上手就可以自己搭建起来。

不过有个点特别说明下。

不需要像singleSPA中cra创建的子类eject后要修改很多配置(其实singleSPA也可以不用修改这么多,只是按照他说的删掉一些文档中说不需要的东西)

1
2
3
4
5
// .rescriptsrc.js
config.output.library = `${name}-[name]`;
config.output.libraryTarget = 'umd';
config.output.jsonpFunction = `webpackJsonp_${name}`; //webpack5高一点的版本这个字段换成了chunkLoadingGlobal,我用的cra创建的,eject出来后webpack是5.5版本。
config.output.globalObject = 'window';

其余的按照qiankun中说的配置就好了。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!