qiankun 是一个基于 single-spa 的微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。
qiankun 孵化自蚂蚁金融科技基于微前端架构的云产品统一接入平台,在经过一批线上应用的充分检验及打磨后,我们将其微前端内核抽取出来并开源,希望能同时帮助社区有类似需求的系统更方便的构建自己的微前端系统,同时也希望通过社区的帮助将 qiankun 打磨的更加成熟完善。
目前 qiankun 已在蚂蚁内部服务了超过 200+ 线上应用,在易用性及完备性上,绝对是值得信赖的。
简单
由于主应用微应用都能做到技术栈无关,qiankun 对于用户而言只是一个类似 jQuery 的库,你需要调用几个 qiankun 的 API 即可完成应用的微前端改造。同时由于 qiankun 的 HTML entry 及沙箱的设计,使得微应用的接入像使用 iframe 一样简单。
解耦/技术栈无关
微前端的核心目标是将巨石应用拆解成若干可以自治的松耦合微应用,而 qiankun 的诸多设计均是秉持这一原则,如 HTML entry、沙箱、应用间通信等。这样才能确保微应用真正具备 独立开发、独立运行 的能力。
如果不考虑体验问题,iframe 几乎是最完美的微前端解决方案了。
iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。
其实这个问题之前这篇也提到过,这里再单独拿出来回顾一下好了。
1url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
2UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中…
3全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
4慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
其中有的问题比较好解决(问题1),有的问题我们可以睁一只眼闭一只眼(问题4),但有的问题我们则很难解决(问题3)甚至无法解决(问题2),而这些无法解决的问题恰恰又会给产品带来非常严重的体验问题, 最终导致我们舍弃了 iframe 方案。
qiankun给出了一个更完整的应用加载方案,提供npm插件import-html-entry, 允许以html文件为应用入口,然后通过一个html解析器从文件中提取js和css依赖,并通过fetch下载依赖
注册app的写法:
const MicroApps = [{
name: 'app1',
entry: 'http://localhost:8080',
container: '#app',
activeRule: '/app1'
}]
qiankun会通过import-html-entry请求http://localhost:8080,得到对应的html文件,解析内部的所有script和style标签,依次下载和执行它们,这使得应用加载变得更易用。import-html-entry暴露出的核心接口是importHTML
核心方法importHTML:
export default function importHTML(url, opts = {}) {}
具体过程如下:
1.检查是否有缓存,如果有,直接从缓存中返回
2.如果没有,则通过fetch下载,并字符串化
3. 基于正则表达式对模板字符串基于正则表达式对模板字符串 进行一次模板解析,主要任务是扫描出外联脚本和外联样式,保存在scripts和styles中
4.调用getEmbedHTML,将外联样式下载下来,并替换到模板内,使其变成内部样式
5.返回一个对象,该对象包含处理后的模板,以及getExternalScripts、getExternalStyleSheets、execScripts等几个核心方法。
getExternalStyleSheets:遍历styles数组,如果是内联样式,则直接返回;否则判断缓存中是否存在,如果没有,则通过fetch去下载,并进行缓存。
getExternalScripts:遍历scripts数组,逐个判断缓存中是否存在,如果没有,则通过fetch去下载,并进行缓存。
execScripts:见js隔离
qiankun通过import-html-entry,可以对html入口进行解析,并获得一个可以执行脚本的方法execScripts。qiankun引入该接口后,首先为该应用生成一个window的代理对象,然后将代理对象作为参数传入接口,以保证应用内的js不会对全局window造成影响。由于IE11不支持proxy,所以qiankun通过快照策略来隔离js,缺点是无法支持多实例场景。
execScripts方法
export function execScripts(entry, scripts, proxy = window, opts = {}) {}
execScripts里实现js隔离的是geval函数内调用的getExecutableScript函数
function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) {
}
把解析出的scriptText(即脚本字符串)用with(window){}包裹起来,然后把window.proxy作为函数的第一个参数传进来,所以with语法内的window实际上是window.proxy。
这样,当在执行这段代码时,所有类似var name = '张三’这样的语句添加的全局变量name,实际上是被挂载到了window.proxy上,而不是真正的全局window上。当应用被卸载时,对应的proxy会被清除,因此不会导致js污染。
如果你的应用内使用了jquery,那么这个jquery对象就会被挂载到window.proxy上。
如果你在代码内直接写window.name = '张三’来生成全局变量,那么qiankun就无法隔离js污染了。
每次加载一个应用,qiankun就初始化这样一个proxySandbox代理沙盒,传入上述execScripts函数中。
export default class ProxySandbox implements SandBox {
...
constructor(name: string) {
...
const proxy = new Proxy(fakeWindow, {
set () { ... },
get () { ... }
}
}
}
在IE下,由于proxy不被支持,所以qiankun退而求其次,采用快照策略实现js隔离。它的大致思路是,在加载应用前,将window上的所有属性保存起来(即拍摄快照);等应用被卸载时,再恢复window上的所有属性,这样也可以防止全局污染。但是当页面同时存在多个应用实例时,qiankun无法将其隔离开,所以IE下的快照策略无法支持多实例模式。
目前qiankun主要提供了两种样式隔离方案,一种是基于shadowDom的;另一种则是实验性的,思路类似于Vue中的scoped属性,给每个子应用的根节点添加一个特殊属性,用作对所有css选择器的约束。
开启样式隔离的语法如下:
registerMicroApps({
name: 'app1',
...
sandbox: {
strictStyleIsolation: true
// 实验性方案,scoped方式
// experimentalStyleIsolation: true
},
})
当启用strictStyleIsolation时,qiankun将采用shadowDom的方式进行样式隔离,即为子应用的根节点创建一个shadow root,整个应用的所有DOM将形成一棵shadow tree,它内部所有节点的样式对树外面的节点无效,所有就实现了样式隔离。
但是这种方案是存在缺陷的。因为某些UI框架可能会生成一些弹出框直接挂载到document.body下,此时由于脱离了shadow tree,所以它的样式仍然会对全局造成污染。
此外qiankun也在探索类似于scoped属性的样式隔离方案,可以通过experimentalStyleIsolation来开启。这种方案的策略是为子应用的根节点添加一个特定的随机属性,如:
再为样式加上:
.app-main {
字体大小:14 px ;
}
// ->
div[data-qiankun-asiw732sde] .app-main {
字体大小:14 px ;
}
它不支持@ keyframes,@ font-face,@ import,@ page(即不会被重写)。
(4)通信
一般来说,微前端中各个应用之前的通信应该是尽量少的,而这依赖于应用的合理拆分。反过来说,如果你发现两个应用间存在极其频繁的通信,那么一般是拆分不合理造成的,这时往往需要将它们合并成一个应用。
然了,应用间存在少量的通信是难免的。qiankun官方提供了一个简要的方案,思路是基于一个全局的globalState对象。这个对象由基座应用负责创建,内部包含一组用于通信的变量,以及两个分别用于修改变量值和监听变量变化的方法:setGlobalState和onGlobalStateChange。
基座应用初始化globalState:
import { initGlobalState, MicroAppStateActions } from 'qiankun';
const initialState = {};
const actions: MicroAppStateActions = initGlobalState(initialState);
export default actions;
这里的actions对象就是我们说的globalState,即全局状态。基座应用可以在加载子应用时通过props将actions传递到子应用内,而子应用通过以下语句即可监听全局状态变化:子应用:
actions.onGlobalStateChange (globalState, oldGlobalState) {
...
}
actions.setGlobalState(...);
此外,基座应用和其他子应用也可以进行这两个操作,从而实现对全局状态的共享,这样各个应用之间就可以通信了。
qiankun官网 qiankun (umijs.org)
微前端(Micro-Frontends)实战(qiankun-vue-react)
1、基座应用
相当于插座,可以是简单的html也可以是任何前端应用
基座应用可以有自己的路由,也可以引用子应用
a. 创建基座应用vue(带router)
vue create qiankun-base
b. 基座应用安装qiankun
安装qiankun
npm i qiankun -S
c. 基座应用引入element
elementUI
npm i element-ui -S
import Vue from 'vue';
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import App from './App.vue';
Vue.use(ElementUI);
new Vue({
el: '#app',
render: h => h(App)
});
d. 修改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';
//引入qiankun
import {registerMicroApps,start} from 'qiankun';
//注册app列表
const apps = [
{
name: 'vueapp',
entry: '//localhost:9999',//自动加载,解析js,动态执行,子应用需解决跨域
container: '#vue',//子应用挂载元素
activeRule: '/vue',//激活规则,访问该规则时,挂载
props: {//传递的参数
a: 1
}
},
{
name: 'reactapp',
entry: '//localhost:20000',//自动加载,解析js,动态执行,需解决跨域
container: '#react',
activeRule: '/react'
}
]
registerMicroApps(apps);//在此可以选择参数按需设置生命周期方法
start();
Vue.use(ElementUI);
new Vue({
router,
render: h => h(App)
}).$mount('#app')
e. 修改App.vue
Home
vue应用
react应用
2、微应用vue
a. 创建vue应用(带router)
vue create qiankun-vue
b. 修改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__) {//默认独立运行
render();
}
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
//暴露三个生命周期钩子
export async function bootstrap(props){};
export async function mount(props){
console.log(props)
render()
};
export async function unmount(props){
instance.$destroy()
};
c. 创建config文件
module.exports = {
devServer: {
port:9999,//设置启动端口
headers:{
'Access-Control-Allow-Origin':'*'//访问控制,允许所有
}
},
configureWebpack:{
output:{
library:'vueApp',
libraryTarget:'umd'
}
}
}
d. 修改router
...
const router = new VueRouter({
mode: 'history',
base: 'vue',//默认路径
routes
})
...
3、微应用react
a. 创建react应用
npx create-react-app my-app
b. 安装yarn
npm install -g yarn
c. 安装react-app-rewired
yarn add react-app-rewired
d. 安装react-router-dom
yarn add react-router-dom
e. 创建config
配置应用启动、打包和跨域参数
config-overrides.js
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.headers = {
'Access-Control-Allow-Origin':'*'
}
return config;
}
}
}
f. 修改package.json
...
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-app-rewired eject"
},
...
g. 修改index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
function render(){
ReactDOM.render(
,
document.getElementById('root')
);
};
if(!Window.__POWERED_BY_QIANKUN__){
render()
}
export async function bootstrap(props){};
export async function mount(props){
render()
};
export async function unmount(props){
ReactDOM.unmountComponentAtNode(document.getElementById('root'))
};
h. 修改app.js
import logo from './logo.svg';
import './App.css';
import {BrowserRouter,Route,Link} from 'react-router-dom'
function App() {
return (
首页
about
(
Edit src/App.js
and save to reload.
Learn React
)
}>
这是react关于页
}>
);
}
export default App;
i. 启动
npm start
to reload.
Learn React
)
}>
这是react关于页
}>
);
}
export default App;
i. 启动
npm start