什么是微前端
- 常见的应用模型:项目中会分为很多模块,会把这些模块组到一个项目中去
- 导致的问题是:应用大 打包构建会慢 项目也会变得不好维护
- 可以把每个模块都拆分成一个个的应用,把这些应用再放到一个主应用中运行,这就是微前端的思想。相当于把很多模块拆分成了不同的应用,可以交给不同的团队来管理,最后可以组装在一起
- 微前端就是按照不同的功能拆分成多个子应用,再把子应用插到当前的主应用中
使用微前端的原因
- 子应用可以使用不同的技术栈,还可以独立部署,解决了前端协同开发问题
微前端落地方案
- single-spa 用于前端微服务化的javascript前端解决方案
- 缺点: 没有处理父子应用或者平级应用的样式隔离问题,js执行隔离问题
- single-spa仅仅做了一件事情:实现了路由劫持和应用加载
- 核心就是可以根据不同的路由加载不同的应用
- qiankun
- 基于single-spa 提供了开箱即用的api
- 解决了沙箱、文件导入等问题
- 总结
- 子应用可以独立构建,运行时动态加载主子应用完全解耦,技术栈无关
- 靠的是协议接入(子应用必须导出bootstrap/mount/unmount方法)
- iframe
- 应用通信
- 基于url 传递消息能力弱
- 基于CustomEvent实现通信
- 基于props主子应用间通信
- 使用全局变量、redux进行通信
- 公共依赖
- cdn externals
- webpack 联邦模块
实战
single-spa
- singleSpa 缺陷 不够灵活 不能动态加载js文件
- 样式不隔离
- 没有js沙箱的机制(加载不同的应用,每次切换的时候用的都是同一个window)
- 用法
- 在父应用中注册一个应用,当条件满足的时候会加载子应用的脚本
- 加载这些脚本,子应用打包出来是一个类库, 会去调bootstrap/mount/unmount
- 调mount时就会走appOptions里的方法,就会把子应用的脚本创建的dom元素放到父应用制定的dom中
- 同时要保证子应用引用的路径都是绝对路径(是相对于自己的路径,不是相对于父路径)
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',
router,
render: h => h(App),
}
const vueLifeCycle = singleSpaVue({
Vue,
appOptions
});
if(window.singleSpaNavigate) {
__webpack_public_path__ = 'http://localhost:10000/'
}
if(!window.singleSpaNavigate) {
delete appOptions.el;
new Vue(appOptions).$mount('#app');
}
export const bootstrap = vueLifeCycle.bootstrap;
export const mount = vueLifeCycle.mount;
export const unmount = vueLifeCycle.unmount;
module.exports = {
configureWebpack: {
output: {
library: 'singleVue',
libraryTarget: 'umd',
},
devServer: {
port: 10000,
}
}
}
const router = new VueRouter({
mode: 'history',
base: '/vue',
routes
})
<template>
<div id="app">
{/* 当路径切换到/vue时 */}
<router-link to="/vue">加载vue应用</router-link>
{/* 子应用child-vue加载的位置 */}
<div id="vue"></div>
</div>
</template>
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() {
return new Promise((resolve ,reject) => {
let script = document.createElement('script');
script.src = url;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
})
}
registerApplication('myVueApp',
async () => {
console.log("加载子应用");
await loadScript('http://localhost:10000/js/chunk-vender.js');
await loadScript('http://localhost:10000/js/app.js');
return window.singleVue;
},
location => location.pathname.startsWith('/vue'),
)
new Vue({
router,
render: h => h(App)
}).$mount('#app');
css隔离方案
- 子应用之间的样式隔离
- 动态样式表,当应用切换时移除老应用样式,添加新应用样式
- 主应用和子应用之间的样式隔离
- bem 约定项目前缀
- css-modules 打包时生成不冲突的选择器名 编译时就完成
- shadow dom 真正意义上的隔离
- css-in-js 不建议
// 如果子应用没有往body上挂些属性的话 shadow dom方案是最靠谱的
// 但是如react项目中 很多弹框都是直接挂在body上的,样式就可能受影响
<body>
<div>
<p>hello world</p>
<div id='shadow'></div>
</div>
<script>
let shadowDOM = shadow.attachShadow({mode: 'closed'});
let pElm = document.createElement('p');
p.innerHTML = 'hello zhuzhu';
let styleElm = document.createElement('style');
styleElm.textContent = `
p{color: red}
`
shadowDOM.appendChild(styleElm);
shadowDOM.appendChild(pElm);
document.body.appendChild(pElm);
</script>
</body>
js沙箱
- 创造一个干净的环境给这个子应用使用,当切换时可以选择丢弃属性和恢复属性
- 沙箱的两种机制
- 快照沙箱
- 快照沙箱只能针对单实例应用场景,如果是多个实例同时挂载的情况则无法解决,只能通过proxy代理沙箱来实现
- proxy沙箱
- 把不同的应用用不同的代理去处理
- 每个应用都创建一个proxy来代理window,好处是每个应用都是相对独立,不需要直接更改全局window属性
class SnapshotSandbox {
constructor() {
this.proxy = window;
this.modifyPropsMap = {};
this.active();
}
active() {
this.windowSnapshot = {};
for(const prop in window) {
if(window.hasOwnProperty(prop)) {
this.windowSnapshot[prop] = window[prop];
}
}
Object.keys(this.modifyPropsMap).forEach(p => {
window[p] = this.modifyPropsMap[p];
});
}
inactive() {
for(const prop in window) {
if(window.hasOwnProperty(prop)) {
if(window[prop] !== this.windowSnapshot[prop]){
this.modifyPropsMap[prop] = window[prop];
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.proxy)
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);
qiankun
- 是基于single-spa做的封装
- 解决的文件引用的问题 / 样式隔离 / js沙箱 / 预加载
<template>
<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></router-view>
<div id="vue"></div>
<div id="react"></div>
</div>
</template>
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',
container: "#vue",
activeRule: '/vue',
},
{
name: 'reactApp',
entry: '//localhost:20000',
container: "#react",
activeRule: '/react',
}
];
registerMicroApps(apps);
start();
new Vue({
router,
render: h => h(App)
}).$mount('#app');
const router = new VueRouter({
mode: 'history',
base: '/vue',
routes
})
module.exports = {
devServer: {
port :10000,
headers: {
'Access-Control-Allow-origin' : '*'
}
},
configureWebpack: {
output: {
library: 'vueApp',
libraryTarget: 'umd'
}
}
}
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');
}
if(window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
if(!window.__POWERED_BY_QIANKUN__) {
render();
}
export async function bootstrap(props) {
}
export async function mount(props) {
render(props);
}
export async function unmount(props) {
instance.$destroy();
}
module.exports = {
webpack: (config) => {
config.output.library = "reactApp";
config.output.libraryTarget = "umd";
config.output.publicPath = "http://localhost:20000/";
return config;
},
devServer: (configFunction) => {
return function (proxy, allowedHost) {
const config = configFunction(proxy, allowedHost);
config.port = "20000";
config.headers = {
"Access-Control-Allow-Origin": "*",
};
return config;
};
},
};
import React from "react";
import ReactDOM from "react-dom";
import "./index.less";
import App from "./App";
function render() {
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
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"));
}
实现
single-spa的实现
import serve from 'rollup-plugin-serve';
export default {
input: './src/single-spa.js',
output: {
file: './lib/umd/single-spa.js',
format: 'umd',
name: 'singleSpa',
sourcemap: true
},
plugins: [
serve({
openPage: '/index.html',
contentBase: '',
port: 3000
})
]
}
<body>
<script src="/lib/umd/single-spa.js"></script>
<script>
singleSpa.registerApplication('app1',
async () => {
return {
bootstrap: async () => {},
mount: async () => {},
unmount: async () => {}
}
},
location => location.hash.startsWith("#/app1"),
{store: {name: "zhuzhu", age: 28}},
);
singleSpa.start();
</script>
</body>
export { registerApplication } from './applications/app.js';
export {start} from './start';
const apps = [];
export function registerApplication(appName, loadApp, activeWhen, customProps) {
apps.push({
name: appName,
loadApp,
activeWhen,
customProps
})
}
export function start() {
}