1 what?什么是微前端?
在日常的开发当中,在我们的项目里面通常会分成很多的模块,比如这张图里的首页,流程中心等模块,可能我们会把这些模块都放在一个应用项目里。这就会导致一些问题:
【暂时假装有图】
- 应用大,打包构建的时候会变得很慢。在生产环境部署的时候,我们可能只有一个很小的改动,但所有的模块都需要打包,会在打包上浪费很多的时间。
- 维护协同困难。当我们的项目做大时,人越来越多了,项目变得越来越不好维护,协同也变成了一个问题。
那这时候我们能够怎么去做呢?我们可以把我们一个应用里的模块都拆分成一个个的子应用,然后再把我们的这些子应用放到一个主应用中去运行,对于这些子应用我们可以交给不同的团队去管理它,最后在将它们组合起来即可。
微前端就是将不同的功能按照不同的维度拆分成多个子应用。通过主应用来加载这些子应用。微前端的核心就是在于拆与合。
2 why?为什要使用微前端?
-
不同的团队在开发同一个应用时所用的技术栈可能不一样
在一个公司里面,部门和团队一般都非常的多,这就会导致有的团队使用的技术栈是react,有的是vue,开发的时候将两门技术整合在一起时比较困难的,所以在传统开发模式下我们一般都用同一种技术栈。如果我们采用微前端的开发方式,将应用划分成一个个的子应用,将子应用打包成一个个lib,把这些lib放到主应用中去调用,这样我们就解决了对技术限制问题。
-
不同的团队都要求独立开发,独立部署
如果每一个人都强耦合在一起开发的,我们在开发部署的将会特别的麻烦和恶心,对于项目来说也不利于管理,所以说独立开发和部署是一个非常重要的问题。在微前端中每个团队管理自己的子应用,所以能够做到独立开发,独立部署。
-
项目中老的应用需要保留
面对老旧,庞大,过时的技术栈的项目代码,如果已经面临不得不重构时,时间充足的情况下是没有问题的,但要是急于交付,可能这个项目就会有很多很多的问题,通常我们都是一部分一部分的去替换,与此同时,如果又有新的需求被要求加到应用里去,对很多人来说这个是一个非常令人头疼的事情。使用微前端可以在不停止对应用新增的时候,同时逐步对应用进行修改升级。
3 how?怎样落地微前端?
实现微前端落地方案常见的有以下三种形式:
(1) 自由组织模式
通过约定或规范进行互联互调,可以借助服务端技术或其他技术能力实现,只要符合微前端三要素则成立,比如 Nginx 路由分发。
(2) 基座模式
通过一个中心基座容器,对应用进行管理,串联应用之间的连接通信。具体实现上有 qiankun 和 Single-SPA 框架等。
(3) 去中心模式
应用以模块形式进行导入导出,彼此之间都可以链接共享资源且相互独立。此方式较为依赖运行环境或语言特性,会存在较大的兼容性问题,比如 Web Components 、Webpcak 5 中的 Module Federation 或者模块映射等技术。
4 基座模式
4.1 Single-SPA
4.1.1 简介
4.1.2 Single-SPA的使用
所涉及到的应用有:
父应用
parent-vue
两个子应用
child-vue
child-react
构建vue子应用
vue create child-vue
npm install single-spa-vue
在main.js中引入single-spa-vue,并通过single-spa-vue来导出必要的生命周期。
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import singleSpaVue from 'single-spa-vue'
Vue.config.productionTip = false
const appOptions = {
el: '#vue', // 挂载到父应用中的id为vue的标签中
router,
render: h => h(App)
}
// 如果是父应用调用子应用,动态设置子应用publicPath
if (window.singleSpaNavigate) {
__webpack_public_path__ = 'http://localhost:10000/';
}
// 如果是子应用在浏览器上打开
if(!window.singleSpaNavigate) {
delete appOptions.el;
new Vue(appOptions).$mount('#app');
}
const vueLifeCycle = singleSpaVue({
Vue,
appOptions
})
// 我定义好了协议,父应用会调用这些方法
export const bootstrap = vueLifeCycle.bootstrap;
export const mount = vueLifeCycle.mount;
export const unmount = vueLifeCycle.unmount;
配置子路由基础路径,防止在父应用中调用时路径不对。修改router/index.js文件
const router = new VueRouter({
mode: 'history',
base: '/vue',
routes
})
配置库打包,在子应用项目中新建一个vue.config.js文件
module.exports = {
configureWebpack: {
output: {
library: 'singleVue',
libraryTarget: 'umd'
},
devServer:{
port:10000
}
}
}
构建react子应用
npx create-react-app child-react
npm i single-spa-react
yarn add react-app-rewired
修改index.js文件
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import singleSpaReact from 'single-spa-react'
const reactLifecycles = singleSpaReact({
React, // 主React对象,一般是暴露在window上或通过require('react') import React from 'react'引入。
ReactDOM, // 主ReactDOMbject,可以通过 require('react-dom') 从'react-dom'中导入ReactDOM。
rootComponent: App // (必填) 将被渲染的顶层React组件。只有在提供了loadRootComponent的情况下才可以省略。
});
// 如果是父应用应用
if (window.singleSpaNavigate) {
window.__webpack_public_path__ = 'http://localhost:20000/';
}
if(!window.singleSpaNavigate) {
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
}
// 我定义好了协议,父应用会调用这些方法
export const bootstrap = reactLifecycles.bootstrap;
export const mount = reactLifecycles.mount;
export const unmount = reactLifecycles.unmount;
打包配置
module.exports = {
webpack: (config) => {
config.output.library = `singleReact`;
config.output.libraryTarget = "umd";
config.output.publicPath = 'http://localhost:20000/'
return config
}
};
修改.env
SKIP_PREFLIGHT_CHECK=true
PORT=20000
WDS_SOCKET_PORT=20000
主应用搭建
vue create parent-vue
npm install single-spa
修改App.vue文件,将子应用挂载到id="vue"标签中
<template>
<div id="app">
<router-link to="/">主页</router-link>
<br />
<router-link to="/vue">去加载vue应用</router-link>
<br />
<router-link to="/react">去加载react应用</router-link>
<!--子应用加载的位置-->
<div id="vue"></div>
<div id="raect"></div>
</div>
</template>
修改main.js文件,注册子应用,需要我们手动的去插入script。
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import {registerApplication, start} from 'single-spa';
Vue.config.productionTip = false
async function loadScript(url) {
return new Promise((resolve, reject) => {
let script = document.createElement('script');
script.src = url;
script.onload = resolve;
script.onerror = reject;
const firstScript = document.getElementsByTagName('script')[0]
firstScript.parentNode.insertBefore(script, firstScript)
})
}
// singleSpa缺陷
// 1、不够灵活 不能动态加载js文件
// 2、样式不隔离 没有js沙箱的机制
registerApplication('singleVue',
async () => {
// systemJs相当于在浏览器里可以去引用es6模块
await loadScript('http://localhost:10000/js/chunk-vendors.js')
await loadScript('http://localhost:10000/js/app.js')
return window.singleVue;
},
loctaion => loctaion.pathname.startsWith('/vue'), // 用户切换到/vue的路径下,我们需要加载刚才定义的子应用
)
registerApplication('singleReact',
async () => {
// systemJs相当于在浏览器里可以去引用es6模块
await loadScript('http://localhost:20000/static/js/bundle.js')
await loadScript('http://localhost:20000/static/js/vendors~main.chunk.js')
await loadScript('http://localhost:20000/static/js/main.chunk.js')
return window.singleReact;
},
loctaion => loctaion.pathname.startsWith('/react'), // 用户切换到/vue的路径下,我们需要加载刚才定义的子应用
)
start();
new Vue({
router,
render: h => h(App)
}).$mount('#app')
运行结果
4.1.3 Single-SPA的缺点
- 将多个子应用都集成在一个页面中,css和js都是很有可能产生冲突,Single-SPA没有做js沙箱和css隔离的处理
- 不够简单,配置比较繁琐。
4.2 qiankun
4.2.1 简介-
-
📦 基于 single-spa 封装,提供了更加开箱即用的 API。
-
📱 技术栈无关,任意技术栈的应用均可 使用/接入,不论是 React/Vue/Angular/JQuery 还是其他等框架。
-
💪 HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。
-
🛡 样式隔离,确保微应用之间样式互相不干扰。
-
🧳 JS 沙箱,确保微应用之间 全局变量/事件 不冲突。
-
⚡️ 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。
-
🔌 umi 插件,提供了 @umijs/plugin-qiankun 供 umi 应用一键切换成微前端架构系统。
4.2.2 qiankun的使用
所涉及到的项目有
父应用
qiankun-base
二个子应用
qiankun-react
qiankun-vue
基座-主应用的搭建
vue create qinkun-base
为了我们的项目样式好看一些,我们在项目里面引入element-ui,并安装qiankun。
npm i element-ui -s
npm install qiankun
在main.js文件中引入乾坤,并注册子应用,启动乾坤
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
Vue.use(ElementUI);
import {registerMicroApps,start} from 'qiankun'
// 子应用配置列表
const apps = [
{
name: 'vueApp', // 应用的名字
entry:'//localhost:10000', // 默认会加载这个html 解析里面的js 动态的执行型(子应用必须支持跨域)
container: '#vue', // 我们要渲染到的容器名
activeRule: '/vue' // 通过那一个路径来激活
},
{
name: 'reactApp',
entry:'//localhost:20000', // 默认会加载这个html 解析里面的js 动态的执行型(子应用必须支持跨域)
container: '#react',
activeRule: '/react',
props: {a: 1}
}
]
registerMicroApps(apps); // 注册应用
start(); // 开启应用
Vue.config.productionTip = false
new Vue({
router,
render: h => h(App)
}).$mount('#app')
修改App.vue里去预留dom节点,等待子应用被加载插入
<template>
<div>
<el-menu :router="true" mode="horizontal">
<el-menu-item index="/">首页</el-menu-item>
<el-menu-item index="/vue">vue应用</el-menu-item>
<el-menu-item index="/react">react应用</el-menu-item>
</el-menu>
<router-view></router-view>
<div id="vue"></div>
<div id="react"></div>
</div>
</template>
<style>
</style>
构建子应用
根据 qiankun 的协议需要导出 bootstrap/mount/unmount 三个引用生命周期钩子函数用于父应用加载子应用时执行,另外父应用会在 window 上添加 POWERED_BY_QIANKUN 属性用于子应用区分当前是否被父应用加载,还是单独加载。所有子应用就围绕钩子函数和属性做相应配置, 各技术技术栈下处理方式基本一致。
构建qiankun-vue子应用
vue create qinkun-vue
修改main.js文件
import Vue from 'vue'
import App from './App.vue'
import router from './router'
let instance = null
function render(props) {
// props父向子通信
instance = new Vue({
router,
render: h => h(App)
}).$mount('#app'); // 这里是挂在到自己的html中, 基座会拿到这个挂在后的html将其插入进去
}
// 如果是qiankun使用的话,那么乾坤会动态的去注入这些路径
if(window.__POWERED_BY_QIANKUN__){
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
// 如果它是独立运行
if(!window.__POWERED_BY_QIANKUN__){render()}
export async function bootstrap() {};
export async function mount(props) {
render(props);
}
export async function unmount(props) {
instance.$destroy();
}
在项目下新建vue.config.js打包配置
module.exports = {
devServer:{
port:10000,
headers:{
'Access-Control-Allow-Origin':'*' // 由于子应用被父应用 fetch 加载,需要允许跨域
}
},
configureWebpack:{
output:{
library:'vueApp', // 定义一个全局使用的名称变量
libraryTarget:'umd' // 设置library的暴露方式,使用 umd 让被webpack打包出来的文件在加载时兼容性更强
}
}
}
构建qiankun-react子应用
npx create-react-app qiankun-react
为了我们能手动自己配置webpack,我们需要增加一个插件
yarn add react-app-rewired
修改package.json
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-app-rewired eject"
}
修改index.js文件
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import {BrowserRouter as Router, Route, Routes, Link} from 'react-router-dom';
import App from './App';
function render() {
ReactDOM.render(
<Router basename={window.__POWERED_BY_QIANKUN__ ? '/react' : '/'}>
<Link to="/">首页</Link>
<Link to="/about">关于</Link>
<Routes>
<Route path="/" exact={true} element={<App />} />
<Route path="/about" element={<div>关于</div>} />
</Routes>
</Router>,
document.getElementById('root')
);
}
if(!window.__POWERED_BY_QIANKUN__){
render()
}
export async function bootstrap() {}
export async function mount() {render();}
export async function unmount() {
ReactDOM.unmountComponentAtNode(document.getElementById("root"));
}
配置启动 config-overrides.js
module.exports = {
webpack: (config) => {
config.output.library = `reactApp`;
config.output.libraryTarget = "umd";
config.output.publicPath = 'http://localhost:20000/'
return config
},
devServer: function (configFunction) {
return function (proxy, allowedHost) {
const config = configFunction(proxy, allowedHost);
config.headers = {
"Access-Control-Allow-Origin": "*",
};
return config;
};
},
};
新建.env文件
SKIP_PREFLIGHT_CHECK=true
PORT=20000
WDS_SOCKET_PORT=20000
效果图展示
4.3 为什么不使用iframe
微前端基座模式的理念非常的像iframe,中有一篇对于微前端Why Not Iframe的思考,这里贴一下里面的优缺点:
- iframe 提供了浏览器原生的硬隔离计划,不论是款式隔离、 js 隔离这类问题通通都能被完满解决。
- url 不同步。浏览器刷新 iframe url 状态失落、后退后退按钮无奈应用。
- UI 不同步,DOM 构造不共享。设想一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时咱们要求这个弹框要浏览器居中显示,还要浏览器 resize 时主动居中。
- 全局上下文齐全隔离,内存变量不共享。iframe 内外零碎的通信、数据同步等需要,主利用的 cookie 要透传到根域名都不同的子利用中实现免登成果。
- 慢。每次子利用进入都是一次浏览器上下文重建、资源从新加载的过程。
所以在微前端的开发中,通常都不使用iframe进行开发。
5. 如何自己实现CSS隔离方案
关于css的隔离问题,qiankun以及帮我们进行了处理,Single-SPA没有去帮我们处理,如果需要我们自己去处理的话可以怎么做呢?
5.1 子应用之间的隔离样式隔离
动态样式表。A,B两个应用分别有自己样式,那么我们在加载A的时候使用A的样式,使用B时移除A的使用B的。
5.2 父子应用的样式隔离
- BEM(Block Element Modifier)约定项目前缀。(可能存在不遵守约定的)
- CSS-Modules 打包时生成不冲突的选择器名。(这个是最常用的,它可以在编译的时候给每个样式表后面加一个随机串,这个串不冲突)
- css-in-js。(不建议使用,样式多了不太好管理)
- Shadow DOM 真正意义上的隔离。
<!--Shadow DOM -->
<body>
<div>
<p>hello</p>
<div id="shadow"></div>
</div>
<script>
let shadowDOM = shadow.attachShadow({mode:'closed'}); // 外界无法访问
let pElm = document.createElement('p');
pElm.innerHTML = 'world';
let styleElm = document.createElement('style');
styleElm.textContent = `
p{color: red}
`
shadowDOM.appendChild(styleElm);
shadowDOM.appendChild(pElm);
// document.body.appendChild(pElm);
// react 项目 弹框
</script>
</body>
6. qiankun里的两种沙箱机制
在多个应用中,任何应用去访问修改window的属性,都有可能造成window的污染。而沙箱就是创造一个干净的环境给子应用使用,当切换子应用时,可以选择丢弃或者恢复属性。
这里介绍两种实现沙箱的方法,也就是qiankun里的使用的沙箱,快照沙箱和代理沙箱。
6.1 快照沙箱
快照沙箱从名字上来看就是一张记录里某一时刻状态的照片,通常会在应用沙箱挂载或卸载时记录快照,在切换时依据快照恢复环境 (无法支持多实例)。
<body>
<script>
class SnapshotSandbox {
constructor() {
this.proxy = window; // window属性
this.modifyPropsMap = {}; // 记录在window上的修改
this.active();
}
// 激活时
active() {
this.windowSnapshot = {}; // window对象的快照
for (const prop in window) {
if (window.hasOwnProperty(prop)) {
// 将window上的属性进行拍照
this.windowSnapshot[prop] = window[prop];
}
}
// 恢复
Object.keys(this.modifyPropsMap).forEach(p => {
window[p] = this.modifyPropsMap[p];
});
}
// 失活时
inactive() {
for (const prop in window) { // diff 差异
if (window.hasOwnProperty(prop)) {
// 将上次拍照的结果和本次window属性做对比
if (window[prop] !== this.windowSnapshot[prop]) {
// 保存修改后的结果
this.modifyPropsMap[prop] = window[prop];
// 还原window
window[prop] = this.windowSnapshot[prop];
}
}
}
}
}
let sandbox = new SnapshotSandbox();
((window) => {
window.a = 1;
window.b = 2;
window.c = 3
console.log(a,b,c)
sandbox.inactive();
console.log(a,b,c)
sandbox.active()
console.log(a,b,c);
})(sandbox.proxy);
</script>
</body>
结果展示:
总结快照沙箱主要分成4步进行:
- 激活时将当前window属性进行快照处理
- 失活时用快照中的内容和当前window属性作比较
- 如果属性发生变化保存到modifyPropsMap中,并用快照还原window属性
- 再次激活时,再次进行快照,并用上次修改的结果还原window
6.2 代理沙箱
使用Proxy对象用于创建对象的代理,实现基本操作的拦截和自定义,用fakeWindow记录修改的window相关的属性,获取的时候如果fakeWindow中没有就从window上拿。
class ProxySandbox {
constructor() {
const rawWindow = window;
const fakeWindow = {}
const proxy = new Proxy(fakeWindow, {
set(target, p, value) {
target[p] = value;
return true
},
get(target, p) {
return target[p] || rawWindow[p];
}
});
this.proxy = proxy
}
}
let sandbox1 = new ProxySandbox();
let sandbox2 = new ProxySandbox();
window.a = 1;
((window) => {
window.a = 'hello';
console.log(window.a)
})(sandbox1.proxy);
((window) => {
window.a = 'world';
console.log(window.a)
})(sandbox2.proxy);
结果展示: