参考资料:
一、背景
微前端是什么?
⼀种类似于微服务的架构,是⼀种由独⽴交付的多个前端应⽤组成整体的架构⻛格,将前端应⽤分解成⼀些更⼩、更简单的能够独⽴开发、测试、部署的应⽤,⽽在⽤户看来仍然是内聚的单个产品。
解决的问题:
- 使各子模块或者子系统进行隔离,独立部署和独立打包。
- 能够使各个子系统进行数据分享,例如用户信息。
- 能够对js、css等进行相互隔离,防止出现污染。
微前端主要特点:
低耦合
:当下前端领域,单⻚⾯应⽤(SPA)是⾮常流⾏的项⽬形态之⼀,⽽随着时间的推移以及应⽤功能的丰富,单⻚应⽤变得不再单⼀⽽是越来越庞⼤也越来越难以维护,往往是改⼀处⽽动全身,由此带来的发版成本也越来越⾼。微前端的意义就是将这些庞⼤应⽤进⾏拆分,并随之解耦,每个部分可以单独进⾏维护和部署,提升效率。不限技术栈
:在不少的业务中,或多或少会存在⼀些历史项⽬,这些项⽬⼤多以采⽤⽼框架类似(Backbone.js,Angular.js 1)的B端管理系统为主,介于⽇常运营,这些系统需要结合到新框架中来使⽤还不能抛弃,对此我们也没有理由浪费时间和精⼒重写旧的逻辑。⽽微前端可以将这些系统进⾏整合,在基本不修改来逻辑的同时来同时兼容新⽼两套系统并⾏运⾏。
微前端具备的能力
二、实现微前端有哪些方案
方案 | 描述 | 优点 | 缺点 |
---|---|---|---|
Nginx路由转发 | 通过Nginx配置反向代理来实现不同路径映射到不同应用,例如www.abc.com/app1对应app1,www.abc.com/app2对应app2,这种方案本身并不属于前端层面的改造,更多的是运维的配置。 | 简单,快速,易配置 | 在切换应用时会触发浏览器刷新,影响体验 |
iframe嵌套 | 父应用单独是一个页面,每个子应用嵌套一个iframe,父子通信可采用postMessage或者contentWindow方式 | 实现简单,子应用之间自带沙箱,天然隔离,互不影响 | iframe的样式显示、兼容性等都具有局限性;太过简单而显得low |
Web Components | 每个子应用需要采用纯Web Components技术编写组件,是一套全新的开发模式 | 每个子应用拥有独立的script和css,也可单独部署 | 对于历史系统改造成本高,子应用通信较为复杂易踩坑 |
组合式应用路由分发 | 每个子应用独立构建和部署,运行时由父应用来进行路由管理,应用加载,启动,卸载,以及通信机制 | 纯前端改造,体验良好,可无感知切换,子应用相互隔离 | 需要设计和开发,由于父子应用处于同一页面运行,需要解决子应用的样式冲突,变量对象污染,通信机制等技术点 |
1、路由转发
使用后端进行路由转发,不同的路径指向不同的系统。
- 技术栈就可以进行隔离,独立开发和部署
- 如果要分享用户信息等,可以通过cookie等技术进行分享
- 每次路由匹配到的话,都会进行刷新,因此也防止了JS,css的污染问题
缺点:每次跳转都相当于重新刷新了一次页面,不是页面内进行跳转,影响体验
优点: 简单,可快速配置
2、iframe嵌套
通过创建一个父程序,在父程序中监听路由的变化,卸载或加载相应的子程序iframe。因每一个iframe就相当于一个单独的页面,所以iframe具有天然的JS和css隔离。在信息共享方面,我们可以使用postMessage或者contentWindow的方式进行。
缺点: iframe样式兼容问题,包括功能性兼容性以及业务性兼容性的问题,另可能会存在一些安全问题:
- 主应用劫持快捷键操作
- 事件无法冒泡顶层,不能冒泡至父程序
- iframe 内元素会被限制在文档树中,视窗宽高限制问题
- 无法共享基础库进一步减少包体积
- 事件通信繁琐且限制多(blog.csdn.net/willspace/a…
优点:实现起来简单,自带沙盒特性
3、web components开发
将每个子应用采用web components进行开发。纯web-components相当于自定义了一个html标签,我们就可以在任何的框架中进行使用此标签。
缺点:需要对之前的子系统都要进行改造,并且通信方面较为复杂
优点: 每个子应用拥有独立的script和css,也可单独部署
4、组合应用路由分发
1、简介
每个子应用单独的打包,部署和运行。父应用基座,基于父应用进行路由管理,全部使用前端进行路由管理。
例如:有子应用A的路由是/testA,子应用B的路由是/testB,那么父应用在监听到/testA的时候,如果此时处于/testB,那么首先会进行一个子应用B的卸载,卸载完成之后,在去加载子应用A。
优点:纯前端改造,相比于路由式,无刷新,体验感良好
缺点:需要解决样式冲突,JS污染问题,通信技术等
2、解决方法
目前的微前端采用的技术方案是组合是应用路由开发,他的缺点是需要自行解决JS的沙盒环境、css的样式重叠或冲突问题、通信技术问题。
1、css冲突解决
- 类似于vue的scoped。在打包的时候,对css选择器加上响应的属性,属性的key值是一些不重复的hash值,然后在选择的时候,使用属性选择器进行选择。
- 可以自定义前缀。在开发子模块之前,需要确定一个全局唯一的css前缀,然后在书写的过程中同一添加此前缀,或在根root上添加此前缀,使用less或sass作用域嵌套即可解。
2、js沙盒环境
沙盒环境最主要做的就是一个js作用域、属性等的隔离:
- diff方法:当我们的子页面加载到父类的基座中的时候,我们可以生成一个map的散列表。在页面渲染之前,我们先把当前的window上的变量等都存储在这个map中;当页面卸载的时候,我们在遍历这个map,将其数据在替换回去。
class Sandbox {
constructor() {
this.cacheMy = {}; // 存放修改的属性,子类属性
this.cacheBeforeWindow = {}; //存储父类属性
}
showPage() {
this.cacheBeforeWindow = {};
//父类存起来,for in 遍历原型链上的属性和方法
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[item] = this.cacheBeforeWindow[item]; // 还原window
}
}
}
}
const diffSandbox = new Sandbox();
// 模拟页面激活
diffSandbox.showPage(); // 激活沙箱
window.info = '我是子应用';
console.log('页面激活,子应用对应的值', window.info);
// 模拟页面卸载
diffSandbox.hidePage();
console.log('页面卸载后,子应用的对应的值', window.info);
// 模拟页面激活
diffSandbox.showPage(); // 重新激活
console.log('页面激活,子应用对应的值', window.info);
- 使用代理的形式:使用proxy监听get和set方法,针对当前路由进行window的属性或方法的存取
const windowMap = new Map();
const resertWindow = {};
let routerUrl = ''; //地址栏目
const handler = {
get: function(obj, prop) {
const tempWindow = windowMap.get(routerUrl);
return tempWindow[prop];
},
set: function(obj, prop, value) {
if (!windowMap.has(routerUrl)) {
windowMap.set(routerUrl, JSON.parse(JSON.stringify(resertWindow)));
}
const tempWindow = windowMap.get(routerUrl);
tempWindow[prop] = value;
},
};
let proxyWindow = new Proxy(resertWindow, handler);
// 首先是父类的啊属性.
proxyWindow.a = '我是父类的a属性的值';
// 改变路由到子类
routerUrl = 'routeA';
proxyWindow.a = '我是routerA的a属性的值'
// 改变路由到父类
routerUrl = '';
console.log(proxyWindow.a); //'我是父类的a属性的值'
// 改变路由到子类
routerUrl = 'routeA';
console.log(proxyWindow.a); //'我是routerA的a属性的值'
三、single-spa
1、使用
1、子类项目
1、安装single-spa-vue:
npm install single-spa-vue --save
2、main.js中加入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 vueLifecycle = singleSpaVue({
Vue,
appOptions
})
// 启动生命周期
export function bootstrap (props) {
console.log('app2 bootstrap')
return vueLifecycle.bootstrap(() => {})
}
// 挂载生命周期
export function mount (props) {
console.log('app2 mount')
return vueLifecycle.mount(() => {})
}
// 卸载生命周期
export function unmount (props) {
console.log('app2 unmount')
return vueLifecycle.unmount(() => {})
}
const appOptions = {
el: '#microApp',
router,
render: h => h(App)
}
// 支持应用独立运行、部署,不依赖于基座应用
if (!process.env.isMicro) {
delete appOptions.el
new Vue(appOptions).$mount('#app')
}
3、配置webpack输出为umd格式:vue.config.js
const package = require('./package.json')
module.exports = {
// 告诉子应用在这个地址加载静态资源,否则会去基座应用的域名下加载
publicPath: '//localhost:8082',
// 开发服务器
devServer: {
port: 8082
},
configureWebpack: {
// 导出umd格式的包,在全局对象上挂载属性package.name,基座应用需要通过这个全局对象获取一些信息,比如子应用导出的生命周期函数
output: {
// library的值在所有子应用中需要唯一,使用
library: package.name,
libraryTarget: 'umd'
}
}
解析:webpack中的path和publicPath
- path:配置文件到最后要输出的目录,必须是绝对路径,例如:
path: './dist'
那就是指定输出到dist目录下 - publicPath:配置默认公共前缀
2、父类项目
1、引入single-spa:
npm install single-spa --save
2、父类基准配置,改造父类main.js
- 配置子应用,将子应用配置通过registerApplication注册进single-spa中
registerApplication → apps(app、activeWhen) → apps.app中loadApp(请求资源) → app.activeWhen(激活条件)
- 挂载后启动start()
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import { registerApplication, start } from 'single-spa'
Vue.config.productionTip = false
// 远程加载子应用
function 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.insertBefore(script, firstScript)
})
}
// 记载函数,返回一个promise
function loadApp(url, globalVar) {
// 支持远程加载子应用
return async () => {
await createScript(url + '/js/chunk-vendors.js')
await createScript(url + '/js/app.js')
// 这里的return很重要,需要从这个全局对象中拿到子应用暴露出来的生命周期函数
return window[globalVar]
}
}
// 子应用列表
const apps = [
{
// 子应用名称
name: 'app1',
// 子应用加载函数,是一个promise
app: loadApp('http://localhost:8083', 'app1'),
// 当路由满足条件时(返回true),激活(挂载)子应用
activeWhen: location => location.pathname.startsWith('/app1'),
// 传递给子应用的对象
customProps: {}
},
{
name: 'app2',
app: loadApp('http://localhost:8082', 'app2'),
activeWhen: location => location.pathname.startsWith('/app2'),
customProps: {}
}
]
// 注册子应用
for (let i = apps.length - 1; i >= 0; i--) {
registerApplication(apps[i])
}
new Vue({
router,
mounted() {
start() // 启动
},
render: h => h(App)
}).$mount('#app')
2、原理
single-spa是一个子应用生命周期的调度者,为应用定义了bootstrap、load、mount、unmount四个生命周期回调。
浏览器首次打开父类应用时候:
- 首先调用registerApplication注册子app;
- 访问路径时,父类应用判断当前的路由是属于哪一个子应用的,判断依据是apps中的activeWhen配置;
- 将当前的子应用划分状态,appToLoad、appToUnmounted、appToMounted;
- 根据子应用的状态,先去执行需要卸载的子应用,卸载完成之后,就会去执行状态为appToLoad、appToMounted的子应用,最后执行相应的回调函数(即子应用中注册的那些生命周期)
3、与qiankun的区别
组合式应用路由分发分为两种解决方案,一种是JS entry,另外一种是html entry
- JS Entry 的方式通常是子应用将资源打成一个 entry script,但这个方案的限制也颇多,如要求子应用的所有资源打包到一个 js bundle 里,包括 css、图片等资源。除了打出来的包可能体积庞大之外的问题之外,资源的并行加载等特性也无法利用上。
- HTML Entry 则更加灵活,直接将子应用打出来 HTML 作为入口,主框架可以通过 fetch html 的方式获取子应用的静态资源,同时将 HTML document 作为子节点塞到主框架的容器中。这样不仅可以极大的减少主应用的接入成本,子应用的开发方式及打包方式基本上也不需要调整,而且可以天然的解决子应用之间样式隔离的问题
qiankun基于single-spa,在single-spa上做了改造,使得接入更加方便:
- 相比于single-spa,qiankun他解决了JS沙盒环境,不需要我们自己去进行处理。在single-spa的开发过程中,我们需要自己手动的去写调用子应用JS的方法(如上面的 createScript方法),而qiankun不需要,乾坤只需要你传入响应的apps的配置即可,会帮助我们去加载
- qiankun在JS Entry基础上使用HTML Entry,single-spa使用JS Entry。
四、qiankun
1、使用
1、主应用安装
yarn add qiankun # 或者 npm i qiankun -S
2、主应用中注入微应用
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
{
name: 'vue1', // app name registered
entry: '//localhost:8080',
container: '#micro-container',
activeRule: '/vue1',
},{
name: 'vue2',
entry: "http://localhost:8081/",
container: '#micro-container',
activeRule: '/vue2',
},
]);
start();
3、微应用导出相应的生命周期
import Vue from 'vue'
import App from './App.vue'
Vue.config.productionTip = false
let app;
let render = () => {
app = new Vue({
render: (h) => h(App),
})
app.$mount('#app')
}
export async function bootstrap() {
console.log('vue1 app bootstraped');
}
/**
* 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
*/
export async function mount() {
render();
}
/**
* 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
*/
export async function unmount() {
app.$destroy()
}
4、配置微应用的打包工具
module.exports = {
devServer: {
headers: {
'Access-Control-Allow-Origin': '*',
},
},
configureWebpack: {
output: {
library: `vue1`,
libraryTarget: 'umd',
},
},
}
2、基于qiankun,源码实现
1、 注入微应用
export const registerMicroApps = (
appList: IAppInfo[],
lifeCycle?: ILifeCycle
) => {
setAppList(appList)
lifeCycle && setLifeCycle(lifeCycle)
}
export const start = () => {
const list = getAppList()
if (!list.length) {
throw new Error('请先注册应用')
}
hijackRoute()
reroute(window.location.href)
list.forEach((app) => {
if ((app as IInternalAppInfo).status === AppStatus.NOT_LOADED) {
prefetch(app as IInternalAppInfo)
}
})
}
2、 路由
const capturedListeners: Record<EventType, Function[]> = {
hashchange: [],
popstate: [],
}
// 劫持和 history 和 hash 相关的事件和函数
// 然后我们在劫持的方法里做一些自己的事情
// 比如说在 URL 发生改变的时候判断当前是否切换了子应用
const originalPush = window.history.pushState
const originalReplace = window.history.replaceState
let historyEvent: PopStateEvent | null = null
let lastUrl: string | null = null
export const reroute = (url: string) => {
if (url !== lastUrl) {
const { actives, unmounts } = getAppListStatus()
Promise.all(
unmounts
.map(async (app) => {
await runUnmounted(app)
})
.concat(
actives.map(async (app) => {
await runBeforeLoad(app)
await runBoostrap(app)
await runMounted(app)
})
)
).then(() => {
callCapturedListeners()
})
}
lastUrl = url || location.href
}
const handleUrlChange = () => {
reroute(location.href)
}
export const hijackRoute = () => {
window.history.pushState = (...args) => {
originalPush.apply(window.history, args)
historyEvent = new PopStateEvent('popstate')
args[2] && reroute(args[2])
}
window.history.replaceState = (...args) => {
originalReplace.apply(window.history, args)
historyEvent = new PopStateEvent('popstate')
args[2] && reroute(args[2])
}
window.addEventListener('hashchange', handleUrlChange)
window.addEventListener('popstate', handleUrlChange)
window.addEventListener = hijackEventListener(window.addEventListener)
window.removeEventListener = hijackEventListener(window.removeEventListener)
}
const hasListeners = (name: EventType, fn: Function) => {
return capturedListeners[name].filter((listener) => listener === fn).length
}
const hijackEventListener = (func: Function): any => {
return function (name: string, fn: Function) {
if (name === 'hashchange' || name === 'popstate') {
if (!hasListeners(name, fn)) {
capturedListeners[name].push(fn)
return
} else {
capturedListeners[name] = capturedListeners[name].filter(
(listener) => listener !== fn
)
}
}
return func.apply(window, arguments)
}
}
export function callCapturedListeners() {
if (historyEvent) {
Object.keys(capturedListeners).forEach((eventName) => {
const listeners = capturedListeners[eventName as EventType]
if (listeners.length) {
listeners.forEach((listener) => {
// @ts-ignore
listener.call(this, historyEvent)
})
}
})
historyEvent = null
}
}
export function cleanCapturedListeners() {
capturedListeners['hashchange'] = []
capturedListeners['popstate'] = []
}
3、 生命周期
import { IAppInfo, IInternalAppInfo, ILifeCycle } from '../types'
import { AppStatus } from '../enum'
import { loadHTML } from '../loader'
let lifeCycle: ILifeCycle = {}
export const setLifeCycle = (list: ILifeCycle) => {
lifeCycle = list
}
export const getLifeCycle = () => {
return lifeCycle
}
export const runBeforeLoad = async (app: IInternalAppInfo) => {
app.status = AppStatus.LOADING
await runLifeCycle('beforeLoad', app)
app = await loadHTML(app)
app.status = AppStatus.LOADED
}
export const runBoostrap = async (app: IInternalAppInfo) => {
if (app.status !== AppStatus.LOADED) {
return app
}
app.status = AppStatus.BOOTSTRAPPING
await app.bootstrap?.(app)
app.status = AppStatus.NOT_MOUNTED
}
export const runMounted = async (app: IInternalAppInfo) => {
app.status = AppStatus.MOUNTING
await app.mount?.(app)
app.status = AppStatus.MOUNTED
await runLifeCycle('mounted', app)
}
export const runUnmounted = async (app: IInternalAppInfo) => {
app.status = AppStatus.UNMOUNTING
app.proxy.inactive()
await app.unmount?.(app)
app.status = AppStatus.NOT_MOUNTED
await runLifeCycle('unmounted', app)
}
const runLifeCycle = async (name: keyof ILifeCycle, app: IAppInfo) => {
const fn = lifeCycle[name]
if (fn instanceof Array) {
await Promise.all(fn.map((item) => item(app)))
} else {
await fn?.(app)
}
}
4、沙箱
export class ProxySandbox {
proxy: any
running = false
constructor() {
const fakeWindow = Object.create(null)
const proxy = new Proxy(fakeWindow, {
set: (target: any, p: string, value: any) => {
if (this.running) {
target[p] = value
}
return true
},
get(target: any, p: string): any {
switch (p) {
case 'window':
case 'self':
case 'globalThis':
return proxy
}
if (!window.hasOwnProperty.call(target, p) && window.hasOwnProperty(p)) {
// @ts-ignore
const value = window[p]
if (typeof value === 'function') return value.bind(window)
return value
}
return target[p]
},
has() {
return true
},
})
this.proxy = proxy
}
active() {
this.running = true
}
inactive() {
this.running = false
}
}
五、systemJs和Module federation
共用依赖的处理:
- 造一个utility module包,在这个包导出所有公用资源内容,并用systemJs的importmap在主应用的index.html里申明
- 使用webpack5 module federation特性实现公用依赖的导入
1、systemJs
可以在浏览器使用 ES6 的 import/export 语法,通过 importmap 指定依赖库的地址。
和 single-spa 没有关系,只是 in-browser import/export 和 single-spa 倡导的 in-browser run time 相符合,所以 single-spa 将其作为主要的导入导出工具。
<script type="systemjs-importmap">
{
"imports": {
"@react-mf/root-config": "//localhost:9000/react-mf-root-config.js"
}
}
</script>
<script>
singleSpa.registerApplication({
name: 'taobao', // 子应用名
app: () => System.import('@react-mf/root-config'), // 如何加载你的子应用
activeWhen: '/appName', // url 匹配规则,表示啥时候开始走这个子应用的生命周期
customProps: { // 自定义 props,从子应用的 bootstrap, mount, unmount 回调可以拿到
authToken: 'xc67f6as87f7s9d'
}
})
</script>
2、模块联邦
mf(模块联邦)是webpack5的新插件,它的主要功能是我们可以将项目中的部分组件或全部组件暴露给外侧使用。webpack5模块联邦让webpack达到线上Runtime的效果,让代码直接在项目间利用CDN共享,不再需要本地安装npm包,构件再发布。
1、使用
模块联邦本身是一个普通的 Webpack 插件 ModuleFederationPlugin
,插件有几个重要参数:
- name:应用的名称。在其他应用查找的时候,会在这个name的作用域下去查找对应的组件。
- remotes:一个映射管理,将其他远程的名称映射成本地的别名,例如上面的我们将其他远程项目app_2映射成了本地app_two
- filename:这些对外暴露的模块存放在哪个文件中。
- exposes**:**对外暴露的模块。只有对外暴露的相应的模块功能才能被使用。
- shared:制定了这个参数,可以让远程加载的模块对应依赖改为使用本地项目的 React 或 ReactDOM。
//webpack配置
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
export default {
plugins: [
new ModuleFederationPlugin({
name: "app_two",
library: { type: "var", name: "app_two" },
filename: "remoteEntry.js",
exposes: {
Search: "./src/Search"
},
shared: ["react", "react-dom"]
})
]
};
在项目中使用,分为两个步骤:
- 第一个步骤首先是引用对应的模块打包后的脚本,例如app_2的remoteEntry.js,可以将对应的模块打包后的脚本部署到cdn上,然后在template.html中,将其引用
- 组件中直接使用import导入即可使用
const HtmlWebpackPlugin = require("html-webpack-plugin");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
module.exports = {
// other webpack configs...
plugins: [
new ModuleFederationPlugin({
name: "app_one_remote",
remotes: {
app_two: "app_two_remote",
app_three: "app_three_remote"
},
exposes: {
AppContainer: "./src/App"
},
shared: ["react", "react-dom", "react-router-dom"]
}),
new HtmlWebpackPlugin({
template: "./public/index.html",
chunks: ["main"]
})
]
};
//输出:<script src="http://localhost:3003/remoteEntry.js"></script>
import { Search } from "app_two/Search";
2、原理
- 加载其他应用的组件通过mf打包后暴露出来的文件remoteEntry.js
- 执行remoteEntry.js,在全局作用域下挂载一个名为在mf中定义的name的属性,这个属性暴露了get和override这两个方法
- 在组件中引用的时候,会通过
__webpack_require__.e
去进行引用。 __webpack_require__.e
中调用__webpack_require__.f
中的对应的方法,从而得到相应的组件。