微前端的使用

WARNING

微前端的概念:构建一个现代 Web 应用所需要的技术/策略/方法,具备多个团队独立开发、部署的特性

一、概述

概念

  • 微前端就是将不同的功能按照不同的维度拆分成多个子应用。通过主应用来加载这些子应用
  • 微前端的核心在于拆, 拆完后在合!

微前端的优势

  • 独立测试部署,各个模块相互独立,互不影响
  • 扩展性高
  • 技术兼容更好,各个模块可以使用不同的技术

微前端的缺点

  • 子应用之间共享资源能力较差
  • 需要对旧的代码改造升级才可以使用

目前主流的微前端解决方案

  • iframe (最大的问题是 刷新页面 路由会丢失 本质刷新的主应用 而不是路由应用 所以被放弃了)
  • single-spa,2018年诞生,是一个用于前端微服务化的JavaScript前端解决方案 (本身没有处理样式隔离,js执行隔离) 实现了路由劫持和应用加载
  • qiankun,2019年诞生,基于single-spa, 提供了更加开箱即用的 API (single-spa + sandbox + import-html-entry) 做到了,技术栈无关、并且接入简单(像iframe一样简单)
  • webpack5的模块联邦,2020年诞生,提供了一种新的解决思路

二、single-spa使用

1 创建子应用

vue create child-vue

npm i single-spa-vue

main.js 入口文件

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import singleSpaVue from 'single-spa-vue'

Vue.config.productionTip = false

// vue2项目的配置选项
const appOptions = {
	el: '#vue', // 挂载到父应用中id为vue的标签中
	router,
	render: h => h(App)
}

// 当父应用 调用我的时候,控制子应用路由跳转的资源引用路径
if (window.singleSpaNavigate) {
	// eslint-disable-next-line no-undef
	__webpack_public_path__  = 'http://localhost:10001/';
}

// 支持子应用独立运行、部署,不依赖于基座应用
if (!window.singleSpaNavigate) {
	delete appOptions.el;
	new Vue(appOptions).$mount("#app");
}

const vueLifeCycle = singleSpaVue({
	Vue,
	appOptions
})

// 子应用的导出协议,父应用会调用,必须导出三个方法bootstrap、mount、unmount
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',//process.env.BASE_URL,
	routes
})

vue.config.js 修改webpack的基础配置

module.exports = {
	configureWebpack: {
		output: {
			library: 'singleVue', // 注入到window上面的名称
			libraryTarget: 'umd' // 打包出的格式
		},
		devServer: {
			port: 10000
		}
	}
}

2 创建父应用

vue create parent-vue

npm i single-spa

main.js 入口文件

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;
		document.head.appendChild(script);
	})
}

// 1 注册应用
registerApplication(
	// 第一个参数:名称 随便取
	'myVueApp',
	// 第二个参数:必须是promise函数
	async () => {
		console.log('加载默默');
		// system JS
		await loadScript(`http://localhost:10001/js/chunk-vendors.js`);
		await loadScript(`http://localhost:10001/js/app.js`);
		return window.singleVue; // bootstrap mount unmount
	},
	// 第三个参数:匹配路径函数,用户切换到/vue的路径下,需要加载第二个参数的定义的子应用
	location => location.pathname.startsWith('/vue'),
	// 第四个参数:自定义参数。包括属性和方法,非必传
	{a:1, b:1}
)
// 2 启动应用
start();

new Vue({
	router,
	render: h => h(App)
}).$mount('#app')

App.vue 修改挂载节点

<template>
	<div id="app">
		<router-link to="/vue">加载vue应用</router-link>
		<!-- 子应用加载的位置 -->
		<div id="vue"></div>
	</div>
</template>

3 single-spa的缺陷

  • 不够灵活
  • 不能动态加载JS文件
  • 样式不隔离
  • 没有JS沙箱的机制

三、CSS隔离方案

1 子应用与子应用的样式隔离

  • Dynamic Stylesheet动态样式表,当应用切换时移除老应用样式,添加新应用样式

2 主应用和子应用的样式隔离

  • BEM(Block Element Modifier) 约定项目前缀
  • CSS-Modules 打包时生成不冲突的选择器名(主流方案,vue的样式加hash后缀)
  • css-in-js
  • Shadow DOM 真正意义上的隔离

3 Shadow DOM

graph

<!DOCTYPE html>
<html lang="en">

<head>
	<meta charset="UTF-8">
	<meta http-equiv="X-UA-Compatible" content="IE=edge">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>Document</title>
</head>

<body>
	<div>
		<p>hello world</p>
		<div id="shadow"></div>
	</div>
	<script>
		// 1 创建影子
		let shadowDom = shadow.attachShadow({ mode: 'open' });
		
		// 2 创建元素
		let pElement = document.createElement('p');
		pElement.innerHTML = 'hello shadow';

		// 3 编写样式
		let styleElement = document.createElement('style');
		styleElement.textContent = `
			p{color:red}
		`
		// 4 将元素和样式都放到影子内部
		shadowDom.appendChild(pElement);
		shadowDom.appendChild(styleElement)
	</script>
</body>

</html>

四、JS沙箱机制

  • 创建一个干净的环境给子应用使用,当切换子应用的时候,可以选择丢弃属性和恢复属性
  • 目的:为了子应用从开始到结束的运行声明周期中,并在切换中都不会影响到全局
  • JS常见的两种沙箱机制:
    1. 快照沙箱,在应用沙箱挂载或卸载时记录快照,在切换时依据快照恢复环境 (无法支持多实例)
    2. Proxy 代理沙箱,不影响全局环境

graph

1 快照沙箱

  • 激活时将当前window属性进行快照处理
  • 失活时用快照中的内容和当前window属性比对,如果属性发生变化保存到modifyPropsMap中,并用快照还原window属性
  • 再次激活时,再次进行快照,并用上次修改的结果还原window
  • 总结一句话,激活拷贝window的属性到缓存中,失活对比缓存,还原window属性
class SnapshotSandbox {
	constructor() {
		this.proxy = window; // window属性
		this.modifyPropsMap = {}; // 记录在window上的修改
		this.active();
	}
	// 激活沙箱
	active() {
		// window对象的快照
		this.windowSnapshot = {};
		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 (this.windowSnapshot[prop] !== window[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) // 1 2 3

	sandbox.inactive();
	console.log(a, b, c) // undefined undefined undefined

	sandbox.active();
	console.log(a, b, c) // 1 2 3
})(sandbox.proxy);

快照沙箱只能针对单实例应用场景,如果是多个实例同时挂载的情况则无法解决,只能通过proxy代理沙箱来实现

2 Proxy 代理沙箱

// 代理沙箱类
class ProxySandbox {
	constructor() {
		// 缓存原始的window对象
		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) // hello
})(sandbox1.proxy);

((window) => {
	window.a = 'world';
	console.log(window.a) // world
})(sandbox2.proxy);

每个应用都创建一个proxy来代理window,好处是每个应用都是相对独立,不需要直接更改全局window属性

五、qiankun使用

qiankun官网open in new window

1 主应用

vue create qiankun-base

npm i qiankun
// App.vue 主应用进行布局

<template>
<!-- 不能有 id="app" -->
	<div>
		<el-menu :router="true" mode="horizontal">
			<!-- 自己的路由应用 -->
			<el-menu-item index="/">Home</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 />
		<div id="vue"></div>
		<div id="react"></div>
	</div>
</template>
// 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';
import { registerMicroApps, start } from 'qiankun'

const apps = [
	{
		name: 'vueApp',
		entry: '//localhost:10001', // 子应用必须支持跨域,利用fetch去请求资源文件
		container: '#vue', // 挂载子应用的容器
		activeRule: '/vue', // 激活的路径
		props: { a: 1 }
	},
	{
		name: 'reactApp',
		entry: '//localhost:20000',
		container: '#react',
		activeRule: '/react'
	}
]
// 注册应用
registerMicroApps(apps);
// 开启
start({
	prefetch: false, // 'all' 取消预加载,不点击路由,不加载
});

Vue.config.productionTip = false
Vue.use(ElementUI);

new Vue({
	router,
	render: h => h(App)
}).$mount('#app')

2 vue子应用

// router/index.js  路由修改

const router = new VueRouter({
  mode: 'history',
  base: '/vue',
  routes
})
// main.js 入口修改

import Vue from 'vue'
import App from './App.vue'
import router from './router'

let instance = null;
function render() {
	instance = new Vue({
		router,
		render: h => h(App)
	}).$mount('#app') // 挂载到自己的html中,基座拿到挂载后的html,插入到占位中
}

// 父应用中运行
if (window.__POWERED_BY_QIANKUN__) {
	// eslint-disable-next-line no-undef
	__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() {
	instance.$destroy();
}
// vue.config.js 服务修改

module.exports = {
	devServer: {
		port: 10001,
		headers: { // 跨域配置
			'Access-Control-Allow-Origin': '*'
		}
	},
	configureWebpack: {
		output: {
			library: 'vueApp',
			libraryTarget: 'umd'
		},
	}
}

3 react子应用

重写webpack的配置

yarn add react-app-rewired --save-dev
// package.json

  "scripts": {
    "start": "react-app-rewired start",
    "build": "react-app-rewired build",
    "test": "react-app-rewired test",
    "eject": "react-app-rewired eject"
  },
// config-overrides.js 修改webpack配置

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;
		};
	},
};
// index.js 配置入口

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { BrowserRouter, Route, Link } from "react-router-dom"


const BASE_NAME = window.__POWERED_BY_QIANKUN__ ? "/react" : "";

function render() {
	ReactDOM.render(
		<BrowserRouter basename={BASE_NAME}>
			<Link to="/">首页</Link>
			<Link to="/about">关于</Link>
			<Route path="/" exact render={() => <h1>hello home</h1>}></Route>
			<Route path="/about" render={() => <h1>hello about</h1>}></Route>
		</BrowserRouter>,
		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"));
}