前言
对于微前端,阿里基于single-spa 实现 微前端框架 —— qiankun 一直是一个很火的存在。
作为一个好学上进的前端从业者,怎么能不丰富一下自己的弹药库呢?
在初体验完qiankun使用之后,便开始浏览qiankun相关源码。
下面为大家带来qiankun mini版实现。
tips:如果各位大佬希望体验 qiankun 的使用,不妨移步官网先体验一波(qiankun.umijs.org/ )
准备工作
实现之前,先了解一下qiankun相关信息
qiankun结构
qiankun 微前端项目,主要由 基座,子应用 组成。
当 基座 的路由发生变更时,qiankun就会对应的挂载相关子应用
因此 当我们希望实现qiankun前,需要先准备几个子应用及基座应用。
下面我创建了三个项目分别是 vue2(基座)、vue2(子应用)、vue3(子应用)
react 不熟就先鸽了
项目目录:
qiankun介绍
qiankun 主要分成两个部分: 基座,子应用:
基座
1.安装qiankun
基座使用时,首先要安装 qiankun
npm i qiankun
2.注册子应用
在基座应用的入口文件中注册子应用
(1) qiankun 中导出 registerMicroApps, start
import { registerMicroApps, start } from 'qiankun';
(2) registerMicroApps 注册子应用
registerMicroApps([
{
name: 'react' // 子应用名称
entry: '//localhost:3000', // 子应用请求
container: '#miniWeb', // 挂载位置
activeRule: '/miniWeb-react', // 基座映射路由
},
{
name: 'vue2',
entry: '//localhost:9002',
container: '#miniWeb',
activeRule: '/miniWeb-vue2',
}
{
name: 'vue3',
entry: '//localhost:9003',
container: '#miniWeb',
activeRule: '/miniWeb-vue3',
},
]);
(3) qiankun 启动
start()
到这里 qiankun 的基座改造就完成了,可以看到核心是 registerMicroApps, start 所以我们要想实现qiankun的话,只需要想办法实现这两个函数即可
子应用
对于子应用需要构造几个生命周期,并抛出:
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
Vue.config.productionTip = false
let instance = null
function render(props) {
const {container} = props
instance = new Vue({
store,
router,
render: h => h(App)
}).$mount(container ? container.querySelector('#app') : '#app') // 看是否微前端的场景,如果是则挂载到指定的根节点下
}
if(!window.__POWERED_BY_QIANKUN__) {
// 如果乾坤不存在,走默认挂载,独立运行
mount({})
}
export async function bootstrap() {
window.xxx = 2131
console.log(window.xxx, 12);
}
// 挂载逻辑
export async function mount(props) {
render(props)
}
// 组件实例卸载流程
export async function unmount() {
instance.$destroy();
instance.$el.innerHTML = '';
instance = null;
}
下面来分析这里做了什么:
- 创建一个变量 instance ,用来暂存vue实例
- 创建一个render函数,接收一个参数props,props中会获取container,即挂载节点
- render函数执行时会对instance 赋值,将其赋值为当前项目的vue实例,并执行mount方法进行挂载
- 判断当前 window 是否存在 POWERED_BY_QIANKUN ,如果不存在则认为当前不在qiankun环境下运行,直接挂载
- 封装三个生命周期 bootstrap、 mount、unmount 分别代表 挂载前,挂载、卸载
- 这三个生命周期函数会被导出,分别处理了不同的工作,bootstrap 负责挂载前的准备工作,mount 会调用 render 函数,执行之后会执行项目的挂载并将实例赋值到instance中,最后 unmount 负责完成instance 的卸载工作
显然上述就是一个完整的组件生成卸载的流程,值得注意的是,qiankun对这些流程进行了封装,最后都会打包到基座上进行使用,但都是后话了。
准备工作
这里准备两个子应用,用于验证微前端:
vue2 入口文件:
import Vue from 'vue'
import App from './App.vue'
import './registerServiceWorker'
import router from './router'
import store from './store'
Vue.config.productionTip = false
let instance = null
function render(props) {
const {container} = props
instance = new Vue({
router,
store,
render: h => h(App)
}).$mount(container ? container.querySelector('#app') : '#app') // 看是否微前端的场景,如果是则挂载到指定的根节点下
}
if(!window.__POWERED_BY_QIANKUN__) {
// 如果乾坤不存在,走默认挂载,独立运行
mount({})
}
export async function bootstrap() {
window.xxx = 2131
console.log(window.xxx, 12);
}
// 挂载逻辑
export async function mount(props) {
render(props)
}
// 组件实例卸载流程
export async function unmount() {
instance.$destroy();
instance.$el.innerHTML = '';
instance = null;
}
// window.unmount = unmount
启动vue2项目:
vue3 入口文件:
import { createApp } from 'vue'
import App from './App.vue'
// import router from './router'
import store from './store'
let instance = null
function render(props) {
const {container} = props
instance = createApp(App)
instance.use(store).mount(container ? container.querySelector('#app') : '#app') // 看是否微前端的场景,如果是则挂载到指定的根节点下
}
if(!window.__POWERED_BY_QIANKUN__) {
// 如果乾坤不存在,走默认挂载,独立运行
mount({})
}
export async function bootstrap() {
window.xxx = 2222
console.log(window.xxx, 123);
}
// 挂载逻辑
export async function mount(props) {
render(props)
}
// 组件实例卸载流程
export async function unmount() {
instance.unmount()
instance = null
}
启动vue3项目:
基座开发
现在开始基座的开发
我们知道qiankun使用的时候需要 导入两个函数
import { registerMicroApps, start } from "./micro-fe/index";
下面就围绕这两个函数的实现去开发
前置操作
创建入口文件
为了便于开发, 先创建micro-fe文件夹,并默认导出registerMicroApps, start 这两个函数,如下:
export const registerMicroApps = () => {
}
export const start = () => {
}
注册子应用映射表(apps)
为了让基座知道如何加载正确的子应用我们还需要提供子应用的注册表。
可能有朋友不清楚这个 注册表 具体是干嘛的,下面简单介绍一个qiankun微前端的原理是什么?
qiankun 表象上有点像 iframe,会在指定节点中嵌入一个子页面,这个子页面是通过链接加载下来的。
所以我们怎么知道什么时候去什么页面呢?
因此我们要在前端维护一个子应用的map,里面注册了子应用需要通过什么链接获取,叫什么名字等信息,这样我们前端在切换子应用的时候才知道应该去哪找对应的子应用
所以我们还需要提供子应用列表:
import { MINI_WEB_MAP, VUE3, VUE2, ACTIVE_RULE } from '../constant/miniWeb';
export const miniWebList = [
{
name: VUE2,
entry: '//localhost:9002', // html应用入口
container: ACTIVE_RULE, // 渲染位置
activeRule: MINI_WEB_MAP[VUE2]
},
{
name: VUE3,
entry: '//localhost:9003', // html应用入口
container: ACTIVE_RULE, // 渲染位置
activeRule: MINI_WEB_MAP[VUE3]
}
];
相关常量:
export const VUE3 = 'vue3';
export const VUE2 = 'vue2';
export const MINI_WEB_MAP = {
[VUE3]: '/subapp/app-vue3',
[VUE2]: '/subapp/app-vue2',
};
export const ACTIVE_RULE = '#subapp-container';
入口文件调用
上面已经完成大概结构了,下面开始在入口完成初步调用,我们再慢慢去完善
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import { Tabs, TabPane } from "element-ui";
// 加载子应用列表
import { miniWebList } from "./miniRouter";
import { registerMicroApps, start } from "./micro-fe/index";
Vue.use(Tabs)
Vue.use(TabPane)
// 注册子应用列表
const apps = miniWebList
registerMicroApps(apps)
// 启动微前端
start()
Vue.config.productionTip = false
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
registerMicroApps 实现
顾名思义 registerMicroApps 就是注册子应用的意思,实现起来也很简单
let _apps = []
export const getApps = () => {
return _apps
}
export const registerMicroApps = (apps) => {
_apps = apps
}
主要是将注册的子应用信息都缓存到叫 _apps 的变量中,并提供获取 _apps 的方法。
start 简介
start函数 的作用是负责启动微前端,qiankun的核心逻辑都在这里,大致可以分成两大模块:
- 劫持路由
- 加载子应用
介绍下start函数的原理:
以vue为例子,vue的路由跳转主要包括hash和history两种方式,本质上都是通过劫持浏览器原生时间实现,同样的qiankun为了可以实现子应用的跳转也选择了劫持路由。
当基座跳转路径名称是出现在apps中的字段时,qiankun就会拦截下来,通过名称去子应用列表中找到当前子应用的详细信息,并将该子应用挂载到基座指定节点中。
因此 start 函数的实现如下所示:
export const start = () => {
// 微前端运行原理:
// 监听路由
rewriteRouter()
// 处理路由及挂载子应用
handleRouter()
}
下面我们来实现这两个函数: rewriteRouter,handleRouter
劫持路由(rewriteRouter)
所以第一步我们要实现劫持路由的方法,代码如下:
import { handleRouter } from './handle-router';
export const rewriteRouter = () => {
// 1.监视路由变化
// history 路由
// history.go, history.back, history.forward 使用 popstate事件监听, 事件:window.onpopstate
window.addEventListener('popstate', () => {
console.log('popstate');
handleRouter();
});
// pushState,replaceState 需要通过函数重写的方式劫持
const rawPushState = window.history.pushState; // 存储 pushState 原函数
window.history.pushState = (...args) => {
rawPushState.apply(window.history, args);
console.log('监视到 pushState 变化了');
handleRouter();
};
const rawReplaceState = window.history.replaceState; // 存储 pushState 原函数
window.history.replaceState = (...args) => {
rawReplaceState.apply(window.history, args);
console.log('监视到 replaceState 变化了');
handleRouter();
};
};
代码解释:
- 导入处理路由及挂载子应用的函数handleRouter (后续实现)
- 导出了一个rewriteRouter函数,用于重写函数。
- 监听 window popstate 当路由更新时执行 handleRouter
- vue在实现路由的时候会赋值window.history.pushState,因此 pushState 不能直接赋值,需要先缓存原来的 pushState 函数,然后重写 pushState 函数,函数触发时再调用原来的 pushState 及 handleRouter ,即:
const rawPushState = window.history.pushState; // 存储 pushState 原函数
window.history.pushState = (...args) => {
rawPushState.apply(window.history, args);
console.log('监视到 pushState 变化了');
handleRouter();
};
- window.history.replaceState 同理
效果如下:
加载子应用(handleRouter)
上面的内容中我们了解到qiankun会劫持路由,当路由发生改变的时候加载子应用,下面我们来看看如何实现子应用的加载,即handleRouter
第一步:初始化 handleRouter 函数
先初始化 handleRouter 函数,完成基本结构:
let lifeCycle = null // 暂存生命周期
let container = null // 挂载节点
let app = null // 存储一个全局的 子应用实例
// 处理路由变化
export const handleRouter = () => {
};
我们导出了一个 叫做 handleRouter 的函数,同时我们声明了三个变量 lifeCycle,container,app,分别表示 暂存生命周期的对象,子应用挂载节点,及当前子应用的对象
第二步:获取 目标子应用
上面我们初始化了 handleRouter 函数,这里开始实现 handleRouter 函数,首先就是获取目标子应用
import { getApps } from './index';
let lifeCycle = null // 暂存生命周期
let container = null // 挂载节点
let app = null // 存储一个全局的 子应用实例
export const handleRouter = () => {
// 创建一个异步任务将更新逻辑放到最后执行防止拿不到目标节点
setTimeout(() => {
const apps = getApps(); // 获取app列表
app = apps.find((item) => window.location.pathname.startsWith(item.activeRule)); // 获取目标app
if (!app) {
// 如果没有匹配到app 则直接返回
return;
}
}, 0);
};
- 我们的挂载需要目标根节点渲染完成之后,才能将子应用挂载到基座上,所以先创建一个 setTimeout 的异步任务。
- 上面在 registerMicroApps 的实现中,我们提供 getApps 方法,所以先通过 getApps() 拿到注册的子应用 apps
- 因为 handleRouter 是路由更新之后的回调,所以window.location.pathname可以拿到 最新的路由,通过 window.location.pathname 和 apps 的映射就可以拿到目标app,把映射到的子应用赋值给 app (子应用实例)
- 如果 没有匹配到 子应用实例,则结束这一次的子应用挂载
第三步:子应用加载
基于上一步,我们已经匹配到目标子应用,接下来就是重中之重将这个子应用挂载到基座上。
核心代码如下:
import { getApps } from './index';
import { importHTML } from './import-html';
let lifeCycle = null // 暂存生命周期
let container = null // 挂载节点
let app = null // 存储一个全局的 子应用实例
export const handleRouter = () => {
// 创建一个异步任务将更新逻辑放到最后执行防止拿不到目标节点
setTimeout(() => {
const apps = getApps(); // 获取app列表
app = apps.find((item) => window.location.pathname.startsWith(item.activeRule)); // 获取目标app
if (!app) {
// 如果没有匹配到app 则直接返回
return;
}
// 加载子应用
container = document.querySelector(app.container); // 获得入口
container.innerHTML = ''
const {template, execScripts} = await importHTML(app)
container.appendChild(template); // 插入目标节点
// 设置全局乾坤变量
window.__POWERED_BY_QIANKUN__ = true // 告知子应用在基座下渲染
lifeCycle = await execScripts()
}, 0);
};
在注册子应用时需要声明当前挂载节点,所以先通过 document 获得入口
container = document.querySelector(app.container); // 获得入口
因为子应用会挂载到目标节点下,如果当前节点下存在内容就需要删除否则会反复叠加
container.innerHTML = ''
通过importHTML函数去加载子应用真实内容,并对内容进行解析,这里会得到两个结果 template, execScripts。
const {template, execScripts} = await importHTML(app)
- template 表示当前子应用的页面模版,这个页面就和我们平时vue的模板页面是一样的,这个模版后面会被挂载到基座目标节点下。
- execScripts,表示执行子应用模版中的js ,举个例子大家都知道vue在页面中使用时都是加载一个打包后的js,当我们执行完这个js之后这个vue实例就会被创建出来,vue实例还会去完成一些初始化的操作,所以qiankun微前端也一样,需要收集子应用页面中的js,在页面中去执行,这里就是封装了一个执行函数,当调用之后就会去执行模版中的js,并且qiankun对于基座要求打包成umd格式,同时导出三个生命周期函数,所以在我们执行execScripts函数之后,可以通过子应用模版上的js拿到这些生命周期
lifeCycle = await execScripts()
第四步 实现importHTML函数
importHTML函数 会接受一个 子应用实例 即app,并返回template, execScripts即子应用模版和js执行函数。 下面我们来看看importHTML函数 的实现
import { fetchResource } from "./utils/fetch-resource";
// 解析html
export const importHTML = async (app) => {
const url = app.entry
// 请求获取子应用 返回内容
const html = await fetchResource(url)
const template = document.createElement('div');
template.innerHTML = html // 将返回内容挂载在自定义的节点下,方便对齐进行操作
const scripts = [...template.querySelectorAll('script')]
// 获取所有script标签代码: [代码, 代码]
function getExternalScripts() {
// 1.客户端渲染需要通过执行js生成内容
// 2.innerhtml 中的script 不会加载执行
return Promise.all(scripts.map(script => {
const src = script.getAttribute('src')
if(!src) {
// 没有src外链,说明是content型 脚本 ,获取脚本下内容
return Promise.resolve(script.innerHTML)
}
// 如果有src外链,则请求然后获取脚本下内容
return fetchResource(src.startsWith('http')?src:`${url}${src}`)
}))
}
// 获取并执行所有的script代码
async function execScripts() {
const scripts = await getExternalScripts()
const module = { exports: {} }
const exports = module.exports
// 因为umd 格式会判断当前环境有没有 module 和 exports, 所以我们可以直接在当前环境构造出来,这样子就会将工厂函数返回结果赋值给module.exports
scripts.forEach((code) => {
// eval 执行的代码可以访问外部作用域
// eval(code);
((window, module, exports) => {
try {
eval(code);
} catch (error) {
console.log(error);
}
})(app.sandbox.box, module, exports);
}) // 遍历执行代码
return module.exports
}
return {
template,
getExternalScripts,
execScripts
};
};
下面来逐行解释 importHTML 的实现。
- 首先导出一个 importHTML 函数,同时函数接受子应用实例。
- 基于子应用实例拿到目标子应用实例的入口链接
- 基于入口链接发起请求,获取页面模板,同时创建div模版,将页面模版挂载到div模版下,此时就得到了挂载到基座下的template。(
页面请求往往是先请求下页面的模版,再由页面模版去请求对应js或者执行模版自带的s)
// 请求获取子应用 返回内容
const html = await fetchResource(url)
const template = document.createElement('div');
template.innerHTML = html // 将返回内容挂载在自定义的节点下,方便对其进行操作
- 由于浏览器安全限制,innerHtml里面的js并不会被执行,所以需要单独调用方法将模版中的script标签都加载下来。
const scripts = [...template.querySelectorAll('script')]
- 抛出 execScripts 函数,用于执行 scripts 即模版中的脚本代码。
- 调用
getExternalScripts加载出真实 script 字符串
// 获取所有script标签代码: [代码, 代码]
function getExternalScripts() {
// 1.客户端渲染需要通过执行js生成内容
// 2.innerhtml 中的script 不会加载执行
return Promise.all(scripts.map(script => {
const src = script.getAttribute('src')
if(!src) {
// 没有src外链,说明是content型 脚本 ,获取脚本下内容
return Promise.resolve(script.innerHTML)
}
// 如果有src外链,则请求然后获取脚本下内容
return fetchResource(src.startsWith('http')?src:`${url}${src}`)
}))
}
async function execScripts() {
const scripts = await getExternalScripts()
...
}
- 为了让基座可以拿到子应用抛出的生命周期,因此会要求子应用使用umd的方式进行打包,打包之后会得到下面类型的代码:
- 因此子应用抛出的生命周期都会在 factory 函数中,作为返回值导出,因为eval执行代码可以读取外层的变量因此可以通过 eval 遍历执行 scripts (模版js列表),通过这种方式去执行模版中的脚本,并且主动构造
module和exports,借助eval读取外层变量的特性将生命周期挂到 module.exports中并返回,这样子基座这执行完 execScripts 函数之后就可以拿到对应子应用的生命周期函数
async function execScripts() {
const scripts = await getExternalScripts()
const module = { exports: {} }
const exports = module.exports
// 因为umd 格式会判断当前环境有没有 module 和 exports, 所以我们可以直接在当前环境构造出来,这样子就会将工厂函数返回结果赋值给module.exports
scripts.forEach((code) => {
// eval 执行的代码可以访问外部作用域
// eval(code);
((window, module, exports) => {
try {
eval(code);
} catch (error) {
console.log(error);
}
})(app.sandbox.box, module, exports);
}) // 遍历执行代码
return module.exports
}
- 通过为了完成环境隔离,执行子应用js的过程中,通常是需要添加沙箱的,即为什么有
app.sandbox.box,如果对沙箱感兴趣的话可以移步这篇文章: juejin.cn/post/726382…
总结
到这里 importHTML 就已经实现完了,对于qiankun而言这个是核心代码,负责子应用如何加载到基座上。
importHTML原理如下:
- 会先加载子应用的模版,并创建一个
template,将子应用的模版以innerHTML的形式加载到template上。 - 由于浏览器安全限制innerHTML里面的脚本代码不能执行,所以先过滤出模版中的脚本标签,然后抛出
execScripts函数,当执行函数时会遍历模版中的脚本标签,基于标签生成真实的脚本代码数组。 - 为了获取子应用的生命周期,qiankun会要求
子应用使用umd的方式进行打包。 - 然后在
execScripts函数中主动构造module和exports,遍历脚本代码数组,通过eval去执行,因为eval执行脚本可以读取外层变量,因此可以通过这一点来做环境隔离,并且可以将子应用的生命周期挂到module.exports中,至此 基座 就可以通过execScripts函数获取子应用的生命周期
第五步 收集生命周期函数
上面的代码中,我们成功的加载了template, execScripts,并基于execScripts生成了子应用的生命周期
因此下面工作就是收集子应用的生命周期以及执行生命周期将子应用渲染到基座上
...
// 处理路由变化
export const handleRouter = () => {
...
// 创建一个异步任务将更新逻辑放到最后执行防止拿不到目标节点
setTimeout(async () => {
...
lifeCycle = await execScripts()
// 将 生命周期 挂载到 子应用列表中
app.mount = lifeCycle.mount
app.unmount = lifeCycle.unmount
app.bootstrap = lifeCycle.bootstrap
app.sandbox.active() // 启动沙箱
// 执行生命周期函数
bootstrap()
mount()
}, 0);
};
// 封装生命周期函数执行函数
const mount = errorCatch(async () => {
console.log('mount');
app.mount && await app.mount({
container
})
})
const unmount = errorCatch(async () => {
console.log('unmount');
app.unmount && await app.unmount()
})
const bootstrap = errorCatch(async () => {
console.log('bootstrap');
app.bootstrap && await app.bootstrap()
})
回想子应用导出的生命周期就可以知道,实际上是涵盖了一个项目的完整生命周期,因此我们只要调用其生命周期函数,就可以让子应用的内容渲染在其返回的模版中,这样子qiankun的基础实现就完成了。
handleRouter 完整实现
import { getApps } from './index';
import { importHTML } from './import-html';
import { errorCatch } from "./utils/error-catch";
import { sandbox } from "./utils/sandbox";
let lifeCycle = null
let container = null
let app = null // 存储一个全局的 子应用实例
// 处理路由变化
export const handleRouter = () => {
// 如果子应用实例存在,则卸载
if(app) {
app.sandbox.inactive() // 关闭沙箱
unmount()
}
// 创建一个异步任务将更新逻辑放到最后执行防止拿不到目标节点
setTimeout(async () => {
console.log('路由变化');
// 2.匹配子应用
// 2.1 获取当前路由路由 window.location.pathname
// 2.2 去apps里面查找
const apps = getApps(); // 获取app列表
app = apps.find((item) => window.location.pathname.startsWith(item.activeRule)); // 获取目标app
if (!app) {
// 如果没有匹配到app 则直接返回
return;
}
// 创建沙箱实例
if (!app.sandbox) {
app.sandbox = new sandbox()
}
// 3.加载子应用
container = document.querySelector(app.container); // 获得入口
container.innerHTML = ''
const {template, execScripts} = await importHTML(app)
container.appendChild(template); // 插入目标节点
// 设置全局乾坤变量
window.__POWERED_BY_QIANKUN__ = true // 告知子应用在基座下渲染
lifeCycle = await execScripts()
// 将 生命周期 挂载到 子应用列表中
app.mount = lifeCycle.mount
app.unmount = lifeCycle.unmount
app.bootstrap = lifeCycle.bootstrap
app.sandbox.active() // 启动沙箱
// 执行生命周期函数
bootstrap()
mount()
}, 0);
};
// 封装生命周期函数执行函数
const mount = errorCatch(async () => {
console.log('mount');
app.mount && await app.mount({
container
})
})
const unmount = errorCatch(async () => {
console.log('unmount');
app.unmount && await app.unmount()
})
const bootstrap = errorCatch(async () => {
console.log('bootstrap');
app.bootstrap && await app.bootstrap()
})
效果演示
上面讲了这么多,下面来看看实际效果吧!
项目链接
感兴趣的朋友也可以直接上github上拉一下这个demo,代码思路还是很简单的: github.com/wenps/miniW…
qiankun原理
通过上面内容的了解,我们大概知道qiankun的原理是什么了,大致由两个部分组成:
- 劫持路由,保证基座路由变更时可以取到对应子应用信息,从而进行加载
- 构造运行环境,这一步比较类似于iframe,相当于开发人员人为的构造的了一个子应用代码的运行环境,通过沙箱进行js隔离,嵌入ShadowDOM完成css隔离等,人为的构造一个隔离的运行环境,给予了开发者极大的灵活度。
总结
到这里mini qiankun的开发就实现了,总体来说实现了微前端的基础功能,可以加载不同技术栈的项目,但是优化的点还很多,微前端的路由处理以及css的隔离这些都还没做,道阻且长。
如果想了解一下qiankun原理的话,这个文章的确是一个不错的选择