本文讲述微前端的发展背景,简单总监微前端的几种解决方案,讲解qiankun的使用案例和大致实现原理。
API
进行通信的小型独立服务组成。微服务的主要思想是:
微服务的主要思路是化繁为简,通过更加细致的划分,使得服务内容更加内聚,服务之间耦合性降低,有利于项目的团队开发和后期维护。把微服务的概念应用到前端就是微前端。
微前端是⼀种架构⻛格,它允许可独⽴交付的前端应⽤程序被组合成⼀个更⼤的整体。
single-spa
在github上开源single-spa
的qiankun
框架问世Module Federation
(Webpack5)把项目中的模块分为本地模块和远程模块,远程模块在运行时异步从所谓的容器中加载。或者说具备哪些能力
抛开single-spa
和qiankun
我们来看一下微前端有哪些实现方案。
优点:
缺点:
将子应用封装成npm包,通过组件的方式引入,在性能和兼容性上是最优的方案,但却有一个致命的缺点,每次发版需要通知接入方同步更新,管理非常困难
google推出的浏览器的原子组件,这里简单介绍下。它由三部分组成
Custom elements
自定义元素Shadow DOM
(影子DOM) 用于将DOM树附加到元素上(与主文档DOM分开) 并控制其关联的功能,不用担心与文档其他部分发生冲突。HTML templates
<template>
和<slot>
元素可以编写不在页面中显示的标记模板,可以作为自定义元素的基础被多次重用关于WebComponet,
Web Component 有以下优势
Web Component 不足之处
sigle-spa
的路由劫持方法生命周期
load
当应用匹配路由时就会加载脚本(非函数,只是一种状态)bootstrap
引导函数 (对接html,应用内容首次挂载到页面前调用)mount
挂在函数unmount
卸载函数(移除事件绑定等内容)unload
非必要(unload之后会重新启动bootstrap流程;借助unload可实现热更新)。我写了一个极简single-spa案例
import-html-entry
包解析HTML
获取对资源进行解析、加载。乾坤的运行流程
案例: 采用React作为主应用基座,接入Vue 技术栈的微应用
npx create-react-app micro-main-app
创建主应用npx vue create micro-sub-app-vue
创建子应用src/index.js
增加以下内容jsimport { registerMicroApps, start } from "qiankun";
registerMicroApps([
{
name: 'vueApp',
entry: '//localhost:3001',
container: '#micro-container',
activeRule: '/app-vue',
},
]);
start();
src/app.jsx
mock 路由跳转jsximport { BrowserRouter as Router, Link } from 'react-router-dom'
import { Menu } from 'antd'
import './App.css'
import { useState } from 'react'
const menus = [
{
key: '/',
title: '主页',
path: '/',
label: <Link to="/">React主应用</Link>,
},
{
key: '/app-vue',
route: '/app-vue',
title: 'vue微应用',
label: <Link to="/app-vue">vue微应用</Link>,
},
]
function App() {
const [selectedKey, setSelectKey] = useState(window.location.pathname)
let style = {
width: '100vw',
height: '100vh',
}
return (
<Router>
{/* <h1>主应用启动成功</h1> */}
<div className="App">
<Menu
selectedKeys={[selectedKey]}
style={{
width: 256,
}}
theme="dark"
mode="inline"
items={menus}
onSelect={e=>setSelectKey(e.key)}
></Menu>
{selectedKey === '/' ?
<div style={Object.assign({
display: 'flex', marginTop: '10vh', fontSize: '40px', justifyContent: 'center',
}, style)}>
React主应用
</div> :
<div id="micro-container" style={style}></div>
}
</div>
</Router>
)
}
export default App
vue.config.js
增加 devServer:{port: '3001'}
src/app.js
jsif (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
render();
}
export async function bootstrap() {
console.log('[vue] app bootstraped');
}
export async function mount(props) {
console.log('[vue] props from main framework mount', props);
render(props);
}
export async function unmount() {
instance.$destroy();
instance = null;
}
以上案例仅开发环境运行,生产环境还要微调一下代码
在生产环境中,我们一般通过nginx配置静态资源的访问,
作为前端人,为了测试生产环境运行效果,我写了一个node脚本,模拟nginx的功能
javascriptconst Koa = require('koa');
const serve = require('koa-static');
const path = require('path');
const fs = require('fs');
const app = new Koa();
// 主应用
app.use(serve(path.resolve(__dirname, './example/micro-main-app/build')));
// 子应用 资源文件
const app1Files = serve(path.resolve(__dirname, './example/micro-sub-app-vue/dist/'));
app.use(async function (ctx, next) {
if (/^\/app-vue\//.test(ctx.req.url) && path.extname(ctx.req.url)) { // 加载子应用资源
ctx.req.url = ctx.req.url.replace(/\/app-vue/, '')
return await app1Files.apply(this, [ctx, next])
} else { // 前端路由都走主应用
let text = await new Promise((resolve, reject) => {
fs.readFile(path.resolve(__dirname, './example/micro-main-app/build/index.html'), 'utf-8', function (error, data) {
if (error) return reject(error)
resolve(data)
})
})
ctx.body = text
next()
}
})
app.listen(8000, () => {
console.log('app start at port 8000');
})
以上案例的完整代码 点击这里
本人对微前端框架原理的研究还没有吃透,这里以自己对qiankun
和single-spa
的理解,简单阐述微前端框架实现的关键技术点及demo。(完整的撸一遍没那个必要,拆分实现的关键技术点会容易记忆)
假如我们可以监听路由变化,进而对路由进行劫持,
- 路由变化时匹配子应用
- 执行子应用的生命周期
- 加载子应用
路由有两种模式 hash
路由和history
路由
hash路由的实现
window.addEventListener('hashchange', onHashChange)
javascript// 监听hashchange
window.addEventListener('hashchange', handleUrlChange);
function handleUrlChange () {
doLifeCycle();
}
function doLifeCycle() {
console.log('todo', `
判断路由改变是否从一个微服务到另一个微服务,
如果是,则卸载当前应用,加载下一个应用,执行前后应用的生命周期
`);
}
// 上述方法不支持拦截 vue-router 中的 router-link
// 采用事件代理处理a标签
document.body.addEventListener('click', function(e) {
if(e.target.tagName !== 'A') return;
const href = e.target.getAttribute('href');
if(/#\//.test(href)) {
doLifeCycle();
}
})
history实现
history 有 .pushState
.replaceState
.go
.forward
.back
五个方法,single-spa 好像只对前两个方法做了代理。
history提供类似hashchange
事件的popstate
事件,但popstate
事件有些不同:
popstate
事件,pushState
和replaceState
或标签改变URL不会出发popstate
事件。好在可以拦截pushState
和replaceState
的调用和标签点击事件来检测URL变化javascript// 拦截浏览器前进后退
window.addEventListener('popstate', function(e) {
doLifeCycle();
});
// 拦截history跳转
const originPush = history.pushState;
history.pushState = (...args) => {
originPush.apply(window.history, args);
doLifeCycle();
};
window.history.replaceState = (...args) => {
originReplace.apply(window.history, args);
doLifeCycle();
};
function doLifeCycle() {
console.log('todo', `
判断路由改变是否从一个微服务到另一个微服务,
如果是,则卸载当前应用,加载下一个应用,执行前后应用的生命周期
`);
}
html解析使用的是一个npm包 import-html-entry
javascriptexport const loadHTML = async (app) => {
const { container, entry } = app;
const { template, getExternalScripts, getExternalStyleSheets } =
await importEntry(entry);
const dom = document.querySelector(container);
if (!dom) {
throw new Error('容器不存在');
}
dom.innerHTML = template;
await getExternalStyleSheets();
const jsCode = await getExternalScripts();
jsCode.forEach((script) => {
const lifeCycle = runJS(script, app);
if (lifeCycle) {
app.bootstrap = lifeCycle.bootstrap;
app.mount = lifeCycle.mount;
app.unmount = lifeCycle.unmount;
}
});
return app;
};
在qiankun中有如下配置可以设置子应用的样式隔离
javascriptstart({
sandbox: {
strictStyleIsolation: true,
experimentalStyleIsolation: true,
}
})
strictStyleIsolation
为每一个子应用包裹ShadowDOM
节点,从而确保微应用的样式不会对全局造成影响。experimentalStyleIsolation
改写子应用的样式,为所有样式规则增加一个特殊的选择器规则来限定其影响范围
.hello
----> div[data-qiankun="vueApp"] .hello
ShadowDOM隔离子应用样式示例代码
html<!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>使用Web Component 隔离样式</title>
<style>
.hello {
background-color: aquamarine;
border: 1px solid #ddd;
margin: 10px;
padding: 20px;
font-size: 30px;
}
</style>
</head>
<body>
<div class="hello">主应用</div>
<div class="sub-hello">
使用了子应用的class
</div>
<div id="container"></div>
<script>
function createElement(appContent) {
const container = document.createElement('div');
container.innerHTML = appContent;
const appElement = container.firstElementChild;
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow;
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
shadow = appElement.createShadowRoot();
}
shadow.innerHTML = innerHTML;
return appElement;
}
const subApp = `<div>
<style>.sub-hello {color: blue}</style>
<p class="sub-hello">子应用</p>
<div>`;
// document.getElementById('container').innerHTML = subApp;
document.getElementById('container').appendChild(createElement(subApp));
</script>
</body>
</html>
补充知识,在一个复杂不熟悉的项目中,如何排除外部样式的影响?
css.comp-root * {
all: inital;
all: revert;
}
.comp-root {
/* 这里些你的样式 */
}
在qiankun
的实现中,包含了两种沙箱,分别为基于Proxy
实现的沙箱和快照沙箱,当浏览器不支持Proxy
会降级为快照沙箱
legacySandbox 设置了三个参数记录全局变量
- addedPropsMapInSandbox 沙箱新增的全局变量
- modifiedPropsOriginalValueMapInSandbox 沙箱更新的全局变量
- currentUpdatedPropsValueMap 持续记录更新的(新增和修改的)全局变量。
javascriptwindow.sex= '男';
let LegacySandbox = new Legacy();
((window) => {
// 激活沙箱
LegacySandbox.active();
window.age = '22';
window.sex= '女';
console.log('激活', window.sex, window.age, LegacySandbox);
})(LegacySandbox.proxy);
单利沙箱的源码很简单,熟悉Proxy,看一眼就能手写出来
javascriptclass Legacy {
constructor() {
this.addedPropsMapInSandbox = {};
this.modifiedPropsOriginalValueMapInSandbox = {};
this.currentUpdatedPropsValueMap = {};
const rawWindow = window;
const fakeWindow = Object.create(null);
this.sandboxRunning = true;
const proxy = new Proxy(fakeWindow, {
set: (target, prop, value) => {
if(this.sandboxRunning) {
if(!rawWindow.hasOwnProperty(prop)) {
this.addedPropsMapInSandbox[prop] = value;
} else if(!this.modifiedPropsOriginalValueMapInSandbox[prop]) {
const originValue = rawWindow[prop]
this.modifiedPropsOriginalValueMapInSandbox[prop] = originValue;
}
this.currentUpdatedPropsValueMap[prop] = value;
rawWindow[prop] = value;
return true;
}
return true;
},
get: (target, prop) => {
return rawWindow[prop];
}
})
this.proxy = proxy;
}
active() {
if (!this.sandboxRunning) {
for(const key in this.currentUpdatedPropsValueMap) {
window[key] = this.currentUpdatedPropsValueMap[key];
}
}
this.sandboxRunning = true;
}
inactive() {
for(const key in this.modifiedPropsOriginalValueMapInSandbox) {
window[key] = this.modifiedPropsOriginalValueMapInSandbox[key];
}
for(const key in this.addedPropsMapInSandbox) {
delete window[key];
}
this.sandboxRunning = false;
}
}
javascriptclass ProxySandbox {
active() {
this.sandboxRunning = true;
}
inactive() {
this.sandboxRunning = false;
}
constructor() {
const rawWindow = window;
const fakeWindow = {};
const proxy = new Proxy(fakeWindow, {
set: (target, prop, value) => {
if(this.sandboxRunning) {
target[prop] = value;
return true;
}
},
get: (target, prop) => {
// 如果fakeWindow里面有,就从fakeWindow里面取,否则,就从外部的window里面取
let value = prop in target ? target[prop] : rawWindow[prop];
return value
}
})
this.proxy = proxy;
}
}
// 测试用例
window.sex = '男';
let proxy1 = new ProxySandbox();
((window) => {
proxy1.active();
console.log('修改前proxy1的sex', window.sex);
window.sex = '111';
console.log('修改后proxy1的sex', window.sex);
})(proxy1.proxy);
console.log('外部window.sex', window.sex);
参考资料:
todo 探索 micro-app
https://github.com/micro-zoe/micro-app/issues/8
本文作者:郭敬文
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!