一、微前端背景
1,什么是微前端
微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。 微前端架构具备以下几个核心价值:
- 技术栈无关
主框架不限制接入应用的技术栈,微应用具备完全自主权 - 独立开发、独立部署
微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新 - 增量升级
在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略 - 独立运行时
每个微应用之间状态隔离,运行时状态不共享
微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用(Frontend Monolith)后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。
2,why not iframe
iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。 其实这个问题之前这篇也提到过,这里再单独拿出来回顾一下好了。
- url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
- UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
- 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
- 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
其中有的问题比较好解决(问题1),有的问题我们可以睁一只眼闭一只眼(问题4),但有的问题我们则很难解决(问题3)甚至无法解决(问题2),而这些无法解决的问题恰恰又会给产品带来非常严重的体验问题, 最终导致我们舍弃了 iframe 方案。
3,为什么笔者要尝试微前端
(1)突然交接的项目为vue,而团队中对于react已经有所积累,新的需求需要在react中开发。
(2)项目交接较频繁,需要将各个项目切分清晰,以防频繁的切割代码仓库等情况。
(3)当前一个项目中包含了多个不同的业务,且代码量较多,需要进行切分。
(4)团队技术探索。
二、微前端框架qiankun介绍
1,qiankun获取微服务及主要渲染流程
2,qiankun的基本使用
(1)base服务注册微服务
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
{
name: 'reactApp',
entry: '//localhost:3000',
container: '#container',
activeRule: '/app-react',
},
{
name: 'vueApp',
entry: '//localhost:8080',
container: '#container',
activeRule: '/app-vue',
},
{
name: 'angularApp',
entry: '//localhost:4200',
container: '#container',
activeRule: '/app-angular',
},
]);
// 启动 qiankun
start();
复制代码
name: 微服务标识,如基座应用就是通过该值找到了微服务的生命周期函数
entry: html地址,未使用js entry主要是因为js的hash值不断变更且会丢置很多源信息
container: 渲染在基座应用的容器
activeRule: 基座应用路由匹配信息
(2)微服务编写生命周期
function render(props) {
const { container } = props;
ReactDOM.render(<App />, container ? container.querySelector('#root') : document.querySelector('#root'));
}
if (!window.__POWERED_BY_QIANKUN__) {
render({});
}
export async function bootstrap() {
console.log('[react16] react app bootstraped');
}
export async function mount(props) {
console.log('[react16] props from main framework', props);
render(props);
}
export async function unmount(props) {
const { container } = props;
ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') : document.querySelector('#root'));
}
复制代码
(3) 微服务开启跨域且更改library
const { name } = require('./package');
module.exports = {
devServer: {
headers: {
// 开启跨域
'Access-Control-Allow-Origin': '*',
},
},
configureWebpack: {
output: {
// 让基座应用获取生命周期
library: `${name}-[name]`,
libraryTarget: 'umd', // 把微应用打包成 umd 库格式
},
},
};
复制代码
三、微前端的核心功能及qiankun的实现
1, 路由劫持
当基座应用更改url的时候,需要匹配到相应微服务,也就是当路由路径符合activeRules的时候自动触发渲染entry里面的内容。qiankun框架借助了single-spa的实现,single-spa重新写了window.history.pushstate和replace的方法。
window.history.pushState = patchedUpdateState(window.history.pushState, "pushState");
window.history.replaceState = patchedUpdateState(window.history.replaceState, "replaceState");
function shouldBeActive(app) {
try {
return app.activeWhen(window.location);
} catch (err) {
handleAppError(err, app, SKIP_BECAUSE_BROKEN);
return false;
}
}
复制代码
2,获取entry
根据entry获取到整个文本内容(开启跨域),将文本内容添加到container中
// get the entry html content and script executor
const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
const appContent = getDefaultTplWrapper(appInstanceId)(template);
...
let initialAppWrapperElement: HTMLElement | null = createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appInstanceId,
);
...
function createElement(
appContent: string,
strictStyleIsolation: boolean,
scopedCSS: boolean,
appInstanceId: string,
): HTMLElement {
const containerElement = document.createElement('div');
// 静态内容添加
containerElement.innerHTML = appContent;
...
}
复制代码
3, 解析加载js内容
步骤2中已经将文本添加到container中,但是此时的js并没有执行,所以还需要将js文件单独获取后,通过new Function或eval等函数执行一遍。包括src的script标签(获取内容后执行),不包括src的则直接执行。并在执行之后,通过document.createComment的方式,将原有代码注释掉。
case SCRIPT_TAG_NAME: {
const { src, text } = element as HTMLScriptElement;
// some script like jsonp maybe not support cors which should't use execScripts
if (excludeAssetFilter && src && excludeAssetFilter(src)) {
return rawDOMAppendOrInsertBefore.call(this, element, refChild) as T;
}
const mountDOM = appWrapperGetter();
const { fetch } = frameworkConfiguration;
const referenceNode = mountDOM.contains(refChild) ? refChild : null;
if (src) {
execScripts(null, [src], proxy, {
fetch,
strictGlobal,
...
});
// 注释原有代码
const dynamicScriptCommentElement = document.createComment(`dynamic script ${src} replaced by qiankun`);
dynamicScriptAttachedCommentMap.set(element, dynamicScriptCommentElement);
...
}
// inline script never trigger the onload and onerror event
execScripts(null, [`<script>${text}</script>`], proxy, { strictGlobal });
// 注释原有代码
const dynamicInlineScriptCommentElement = document.createComment('dynamic inline script replaced by qiankun');
dynamicScriptAttachedCommentMap.set(element, dynamicInlineScriptCommentElement);
return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicInlineScriptCommentElement, referenceNode);
}
复制代码
4,获取css内容
qiankun通过使用动态加载和卸载的方式,在渲染的时候加载style在不渲染时卸载style。来隔离css样式文件。也包含了style和link两种情况。css.process函数为添加样式方法。
case STYLE_TAG_NAME: {
...
const mountDOM = appWrapperGetter();
if (scopedCSS) {
// exclude link elements like <link rel="icon" href="favicon.ico">
const linkElementUsingStylesheet =
element.tagName?.toUpperCase() === LINK_TAG_NAME &&
(element as HTMLLinkElement).rel === 'stylesheet' &&
(element as HTMLLinkElement).href;
if (linkElementUsingStylesheet) {
const fetch =
typeof frameworkConfiguration.fetch === 'function'
? frameworkConfiguration.fetch
: frameworkConfiguration.fetch?.fn;
stylesheetElement = convertLinkAsStyle(
element,
(styleElement) => css.process(mountDOM, styleElement, appName),
fetch,
);
dynamicLinkAttachedInlineStyleMap.set(element, stylesheetElement);
} else {
css.process(mountDOM, stylesheetElement, appName);
}
}
...
}
// 添加样式文件
process(styleNode: HTMLStyleElement, prefix: string = '') {
if (styleNode.textContent !== '') {
const textNode = document.createTextNode(styleNode.textContent || '');
this.swapNode.appendChild(textNode);
const sheet = this.swapNode.sheet as any; // type is missing
const rules = arrayify<CSSRule>(sheet?.cssRules ?? []);
const css = this.rewrite(rules, prefix);
// eslint-disable-next-line no-param-reassign
styleNode.textContent = css;
// 删除原来的文件
this.swapNode.removeChild(textNode);
return;
}
}
复制代码
四、核心问题
1, 如何获取到微服务的生命周期函数
将生命周期函数挂在到window[app.name]中,基座应用便可以直接获得mount,boostrap,unmout函数。
const { name } = require('./package');
module.exports = {
configureWebpack: {
output: {
library: `${name}-[name]`,
libraryTarget: 'umd', // 把微应用打包成 umd 库格式
},
},
};
复制代码
2,如何实现js隔离
qiankun创建了沙盒,将window下的对象通过proxy代理的方式返回,并在下一个微服务mount的时候重新代理。这里又分为快照沙盒和代理沙盒
快照沙盒,如下,在微服务加载对当前的window进行拍照,在inactive的时候进行还原。该方法在不支持proxy的浏览器中使用,但是因为window的对象过于庞大,进行快照十分消耗性能。
export default class SnapshotSandbox implements SandBox {
proxy: WindowProxy;
name: string;
type: SandBoxType;
sandboxRunning = true;
private windowSnapshot!: Window;
private modifyPropsMap: Record<any, any> = {};
constructor(name: string) {
this.name = name;
this.proxy = window;
this.type = SandBoxType.Snapshot;
}
active() {
// 记录当前快照
this.windowSnapshot = {} as Window;
iter(window, (prop) => {
this.windowSnapshot[prop] = window[prop];
});
// 恢复之前的变更
Object.keys(this.modifyPropsMap).forEach((p: any) => {
window[p] = this.modifyPropsMap[p];
});
this.sandboxRunning = true;
}
inactive() {
this.modifyPropsMap = {};
iter(window, (prop) => {
if (window[prop] !== this.windowSnapshot[prop]) {
// 记录变更,恢复环境
this.modifyPropsMap[prop] = window[prop];
window[prop] = this.windowSnapshot[prop];
}
});
if (process.env.NODE_ENV === 'development') {
console.info(`[qiankun:sandbox] ${this.name} origin window restore...`, Object.keys(this.modifyPropsMap));
}
this.sandboxRunning = false;
}
}
复制代码
Proxy沙盒,内部维护一个updateValueMap对象,通过代理window,来动态返回该对象中的值,起到保存window的作用。
// 代理沙盒
const proxy = new Proxy(fakeWindow, {
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
if (this.sandboxRunning) {
...
if (variableWhiteList.indexOf(p) !== -1) {
// @ts-ignore
globalContext[p] = value;
}
...
}
},
get: (target: FakeWindow, p: PropertyKey): any => {
...
const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : globalContext;
return getTargetValue(boundTarget, value);
},
});
// inactive还原
inactive() {
...
variableWhiteList.forEach((p) => {
if (this.proxy.hasOwnProperty(p)) {
// @ts-ignore
delete this.globalContext[p];
}
});
this.sandboxRunning = false;
}
复制代码
3,CSS隔离
当前css隔离一般有如下几种方式:
(1)css module,无法保证引入的第三方依赖,比如第三方修改了全局的样式。
(2)shadow dom,因为只在当前dom下有效,而实际场景中往往会插入到全局样式中,如Modal,就会造成样式丢失。
(3)动态加载/卸载CSS,也是qiankun采用的方式,该方法无法隔离基座应用和当前的微服务,以及一页面多微服务的场景。
4,如何进行通信
目前推荐的方法为自定义事件的方式,且不赞成微服务之间的通信。
5,如何一页面加载多微服务
qiankun教程中的方式为一页面一微服务,但我们可以通过手动加载APP的方式来达到一页面多微服务的应用。
import { loadMicroApp } from 'qiankun';
this.microApp = loadMicroApp({
name: 'app1',
entry: '//localhost:1234',
container: this.containerRef.current,
props: { brand: 'qiankun' },
});
复制代码
五、参考资料
1,qiankun官网
2,云雀
3,qiankun源码
4, single-spa源码
5,有知B站分享