微前端的使用
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
- Shadow DOM 可以实现真正的隔离机制,即隐藏DOM树
<!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常见的两种沙箱机制:
- 快照沙箱,在应用沙箱挂载或卸载时记录快照,在切换时依据快照恢复环境 (无法支持多实例)
- Proxy 代理沙箱,不影响全局环境
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使用
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"));
}