一、背景
通过实践,理解微前端的核心逻辑
二、概念
微前端涉及主应用和子应用,能很好的隔离和自由组合多个子应用形成完整的产品形态,子应用能独立开发部署维护升级,自由选择技术react/vue。
- 主应用: qiankun-base, 负责管理子应用列表,匹配路由后选择对子应用进行加载,卸载等操作
- 子应用: qianku-react,基于react搭建的乾坤子应用
- 子应用: qiankun-vue, 基于vue搭建的乾坤子应用
子应用的接入要求:
- 导出三个必要的生命周期钩子函数
- bootstrap 渲染之前
- mount 渲染函数
- unmount 卸载函数
- 生命周期函数必须返回promise
三、实践
3.1 新建子应用qianku-react
新建一个react项目,部署在http://localhost:40000/ 改造以支持主应用的调用,改动前的react项目入口逻辑是直接就渲染了:
js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.querySelector('#root')
);
现在为了能让微前端进行自由管控渲染的执行,需要将之前的逻辑封装成一个函数
js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
function render(props) {
const { container } = props;
// 主应用渲染的时候,会传入一个容器id进来,有的话要优先使用,没有的话,就默认子应用独立渲染
const containerDom = container
? container.querySelector('#root')
: document.querySelector('#root');
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
containerDom
);
}
// 如果是在乾坤里面加载,则子应用应该跳过默认的加载逻辑,让主应用来控制当前应用的加载(匹配到子应用的路由标识)
if (!window.__POWERED_BY_QIANKUN__) {
render({});
}
/** 暂时保持空函数 */
export async function bootstrap() {}
/**
* 挂着子应用的函数入口
* @param {*} props
*/
export async function mount(props) {
render(props);
}
/**
* 卸载子应用的函数
* @param {*} props
*/
export async function unmount(props) {
const { container } = props;
const containerDom = container
? container.querySelector('#root')
: document.querySelector('#root');
ReactDOM.unmountComponentAtNode(containerDom);
}
3.2 新建子应用qiankun-vue
新建一个vue项目,部署在http://localhost:5000/
如上,默认的vue项目入口文件 main.js,渲染逻辑:
js
import Vue from 'vue';
import App from './App.vue';
import router from './router';
import './public-path';
Vue.config.productionTip = false;
new Vue({
router,
render: (h) => h(App),
}).$mount('#app');
改造后
js
import Vue from 'vue';
import App from './App.vue';
import router from './router';
import './public-path';
Vue.config.productionTip = false;
let instance = null;
function render(props) {
const { container } = props;
// 主应用渲染,会指定子应用的挂载位置container,需要兼容
const containerDom = container
? container.querySelector('#app')
: document.querySelector('#app');
// 这里需要保存一下实例,方便后续对这个实例进行销毁操作
instance = new Vue({
router,
render: (h) => h(App),
}).$mount(containerDom);
}
// 如果子应用独立运行,则手动调用渲染, 否则如果是qiankun使用到了,则会动态注入路径,解决webpack的资源加载问题
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
} else {
mount({});
}
// 根据 qiankun 的协议需要导出 bootstrap/mount/unmount
export async function bootstrap(props) {}
/**
* 子应用加载函数,主应用会在合适的时机调用(匹配到路由规则时)
* @param {*} props
*/
export async function mount(props) {
render(props);
}
/**
* 卸载函数
* @param {*} props
*/
export async function unmount(props) {
instance.$destroy();
instance.$el.innerHTML = '';
instance = null;
}
3.3 新建主应用qiankun-base
现在我们把上面的两个子应用分别运行起来, vue子应用运行在40000端口,react子应用运行在50000端口,现在我们直接新建主应用,运行在3000端口。
- 在主应用的入口处,我们能维护一份子应用列表的配置
js
const apps = [
{
name: 'vueApp',
entry: 'http://localhost:40000/',
container: '#vue', // 要渲染到的容器名id
activeRule: '/vue' // 我们自定义通过哪一个路由来激活这个子应用的渲染
},
{
name: 'reactApp',
entry: 'http://localhost:50000/',
container: '#react',
activeRule: '/react', // 我们自定义通过哪一个路由来激活这个子应用的渲染
}
];
- 在主应用中需要留位置给子应用渲染
js
<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 v-show="$route.name"></router-view>
<!-- 微前端主应用为 vue 和 react 两个子应用渲染预留位置 -->
<div id="vue"></div>
<div id="react"></div>
</div>
</template>
- 现在到了核心流程环节了 核心思路是:
- 监听路由变化
- 匹配路由
- 根据路由去处理当前子应用的加载和渲染,和上一个子应用的卸载
那么我们如何用代码实现上面的逻辑呢?请接着往看下文进行分析。新建如下的目录结构
模块的使用:模块导出两个函数,registerMicroApps 用于注册子应用,start用于启动微前端渲染流程,使用如下
js
import { registerMicroApps, start } from './micro-base/index';
const apps = [
{
name: 'vueApp',
entry: 'http://localhost:40000/',
container: '#vue', // 要渲染到的容器名id
activeRule: '/vue' // 我们自定义通过哪一个路由来激活这个子应用的渲染
},
{
name: 'reactApp',
entry: 'http://localhost:50000/',
container: '#react',
activeRule: '/react', // 我们自定义通过哪一个路由来激活这个子应用的渲染
}
];
// 根据应用列表,注册应用
registerMicroApps(apps);
// 启动微前端渲染流程
start();
回忆一下,微前端要做的事:1)监视路由变化,2)根据路由匹配子应用 3)加载子应用 4)渲染子应用
- index.js
js
import { handleRouter } from './handleRouter';
import { rewriteRouter } from './rewriteRouter';
let _qiankunSubApps = []; // 记录子应用列表
export const getApps = () => _qiankunSubApps; // 提供获取接口
export const registerMicroApps = (apps) => {
// 初始化的时候,存一份apps配置
_qiankunSubApps = apps;
};
export const start = () => {
// 重写路由,因为我们要拦截监听全部的路由变化,根据路由去筛选让哪一个app进行加载以及卸载
rewriteRouter();
//默认执行一次初始执行的子应用渲染逻辑
handleRouter();
};
现在我们研究下rewriteRouter的逻辑,qiankun-base/src/micro-base/rewriteRouter.js
js
import { handleRouter } from './handleRouter';
// 因为假设已经渲染子应用了,切换应用的时候,要对上一个应用进行卸载,对当前应用进行加载,所以要维护上一次的路由
let prevRoute = '';
let nextRoute = window.location.pathname;
export const getPrevRoute = () => prevRoute;
export const getNextRoute = () => nextRoute;
window.getNextRoute = getNextRoute;
window.getPrevRoute = getPrevRoute;
export const rewriteRouter = () => {
// 拦截pushState,给我们一个机会在路由切换前后做一些事情
const rawPushState = window.history.pushState;
window.history.pushState = (...args) => {
// 导航前
prevRoute = window.location.pathname;
rawPushState.apply(window.history, args); // 真正的改变历史记录
nextRoute = window.location.pathname;
// 导航后
handleRouter();
};
// 拦截replaceState 也要做同样的事情,核心是为了正确追踪上一个路由和当前路由
const rawReplaceState = window.history.replaceState;
window.history.replaceState = (...args) => {
prevRoute = window.location.pathname;
rawReplaceState.apply(window.history, args);
nextRoute = window.location.pathname;
handleRouter();
};
// 监听popstate事件,这个事件触发的时候,说明路由发生了切换
window.addEventListener('popstate', () => {
// popstate 触发的时候,路由已经完成导航
prevRoute = nextRoute;
nextRoute = window.location.pathname;
handleRouter();
});
};
上面的逻辑,我们能正确最终路由的变化了,然后我们根据变化前后的路由来处理微前端的加载和卸载逻辑,调用handleRouter这个方法,qiankun-base/src/micro-base/handleRouter.js
js
import { importHTML } from './importHtml';
import { getPrevRoute, getNextRoute } from './rewriteRouter';
import { getApps } from './index';
export const handleRouter = async () => {
// 根据路由获取上一个应用和下一个即将加载的应用,切换之前的应用需要卸载,当前应用需要加载
const apps = getApps();
const prevApp = apps.find((item) => {
return getPrevRoute().startsWith(item.activeRule);
});
if (prevApp) {
await unmount(prevApp); // 如果有上一个应用,卸载上一个路由应用
}
// 获取下一个路由应用配置
const app = apps.find((item) => getNextRoute().startsWith(item.activeRule));
if (!app) return;
// 注入微前端的配置全局环境变量
window.__POWERED_BY_QIANKUN__ = true;
window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = app.entry;
// 加载子应用,通过app的入口文件,获取子应用的模版文件和js文件
const { template, execScripts } = await importHTML(app.entry);
// 将子应用的模版文件挂载到主应用的容器中
const container = document.querySelector(app.container);
container.appendChild(template);
// 获取并保存子应用导出的微前端的生命周期函数
const appExports = await execScripts();
app.bootstrap = appExports.bootstrap;
app.mount = appExports.mount;
app.unmount = appExports.unmount;
// 启动子应用的加载逻辑
await bootstrap(app);
await mount(app);
};
async function bootstrap(app) {
await app.bootstrap();
}
async function mount(app) {
await app.mount({
container: document.querySelector(app.container),
});
}
async function unmount(app) {
await app.unmount({
container: document.querySelector(app.container),
});
}
上面的代码,处理了根据路由变化,找到对应的子应用并加载,整个流程其实已经完了,现在只需要看一下细节,比如importHTML这个函数做了些什么qiankun-base/src/micro-base/importHtml.js
js
import { loadResource } from './LoadResource';
export const importHTML = async (url) => {
const html = await loadResource(url); // 根据子应用的入口,获取子应用的模版文件
const template = document.createElement('div');
template.innerHTML = html;
// 获取模版文件中的所有 script 标签
const scripts = template.querySelectorAll('script');
// 获取所有 script 标签的代码
function getExternalScripts() {
return Promise.all(
Array.from(scripts).map((script) => {
const src = script.getAttribute('src');
if (!src) {
return Promise.resolve(script.innerHTML);
} else {
return loadResource(src.startsWith('http') ? src : `${url}${src}`);
}
})
);
}
// 获取并执行所有的 script脚本代码
async function execScripts() {
const scripts = await getExternalScripts();
//手动构造一个 CommonJS 模块环境
const module = { exports: {} };
const exports = module.exports;
scripts.forEach((code) => {
// eval执行的代码可以访问外部变量
eval(code);
});
return module.exports;
}
return {
template,
getExternalScripts,
execScripts,
};
};
最后还有一个资源加载函数 qiankun-base/src/micro-base/LoadResource.js
js
export const loadResource = (url) => fetch(url).then((res) => res.text());
如上,就是整个微前端的核心逻辑。
todo:
- js沙箱隔离机制
- 样式隔离