为什么需要微前端
-
巨石应用(Monolith Application)的产生
我们平常在开发前端应用时,通常会使用
SPA
这样的架构(如下图),通过路由分发进入不同的页面(组件),而每个页面又由各种组件的构成,随着业务的不断推进,新的功能不断增加,应用内的模块不断增多,应用代码量不断增加,我们部署和维护的难度也在不断增加,应用逐渐变成一个巨石应用。- 巨石应用带来的问题(Disadvantages of Monolith)
- Difficult to deploy and maintain(难以部署和维护)
- Obstacle to frequent deployments(会有频繁部署的问题)
- Dependency between unrelated features(会出现不相关的依赖特征)
- Makes it diffcult to try out new technologies/framework(难以尝试新的技术和框架) 为了解决这个问题,提出了微前端的概念。
- 巨石应用带来的问题(Disadvantages of Monolith)
-
微前端(Micro Frontend)
-
概念(concept)
微前端是一种类似于微服务的架构,是一种由独立交付的多个前端应用组成整体的架构风格,将前端应用分解成一些更小、更简单的能够独立开发、测试、部署的应用,而在用户看来仍然是内聚的单个产品。[1]
-
架构
微前端的架构如下图所示,我们将一个总的应用通过某种方式分解成一些更小的应用,每个小的应用都是独立开发,部署的,最后通过父应用路由组合起来,形成一个前端应用。
-
优缺点
- Advantage
- 应用自治。只需要遵循统一的接口规范或者框架,以便于系统集成到一起,相互之间是不存在依赖关系的。
- 单一职责。每个前端应用可以只关注于自己所需要完成的功能。
- 技术栈无关。可以使用 原生JS 的同时,又可以使用 React 和 Vue。
- Disadvantage
- 维护问题
- 架构复杂
- Advantage
-
应用场景(scenes)
- 中台/后台
- 大型的Web应用
-
微前端实现方式与原理分析(principle)
微前端实现方式
- iframe
- why not iframe[2]
- 性能问题,iframe每次进入子应用时都会重新渲染,资源也会重新加载
- 全局上下文完全隔离,内存变量不共享。cookie不共享,需要搭建特定的channel进行通信
- DOM结构不共享,无法使用全局弹窗
- url不同步,刷新页面,无法使用前进/后退
- why not iframe[2]
- 基于系统基座实现
- 主应用搭建一个基座,在基座中渲染子应用
- qiankun,singleSPA
微前端框架流程图(基于系统基座)
如上图所示,运行一个微应用需要经过
注册
,初始化
,运行
三个步骤。
- 注册是将微应用的信息保存,并且创建全局状态管理;
- 初始化会获取注册的应用所在的地址的HTML文件,并且提取静态js,css文件;
- 运行或路由发生变化时,会运行静态的js,css文件,调用生命周期渲染微应用;
- 需要实现的功能
- 加载HTML
- 加载JS文件
- 定义生命周期
- 使用沙箱隔绝执行作用域
- 创建应用间通信channel
- 路由监听
- 样式隔离
实现流程
1.获取应用
- 获取HTML文件
在创建构造函数之前,我们需要先实现一个获取HTML字符串,并且截取静态js,css文件的方法。在这里我做了一种兼容vite
的方式 - loadHtml
async function loadHtml( entry: string, type: LoadScriptType ): Promise<LoadHtmlResult> { const data = await fetch(entry, { method: 'GET', }); let text = await data.text(); const scriptArr = text .match(scriptReg) ?.filter((val) => val) .map((val) => (isHttp.test(val) ? val : `${entry}${val}`)); const styleArr = text .match(styleReg) ?.filter((val) => val) .map((val) => (isHttp.test(val) ? val : `${entry}${val}`)); text = text.replace(/(<script.*><\/script>)/g, ''); console.log(scriptArr); const scriptText: string[] = []; if (type === 'webpack' && scriptArr) { for (const item of scriptArr) { let scriptFetch = await fetch(item, { method: 'GET' }); scriptText.push(await scriptFetch.text()); } } return { entry, html: text, scriptSrc: type === 'webpack' ? scriptText : scriptArr || [], styleSrc: styleArr || [], }; }
- 运行JS(runScript),定义生命周期
获取到HTML文件以后,需要定义一个执行微应用js文件,并且调用生命周期的方法 - 生命周期
- beforeMount 在渲染应用之前调用的函数
- mount 挂载微应用时调用渲染函数
- unmount 卸载微应用时调用
/** 生命周期函数 */
export type LoadFunctionResult = {
beforeMount: () => void;
mount: (props: LoadFunctionMountParam) => void;
unmount: (props: UnloadFunctionParam) => void;
};
export type LoadScriptType = 'esbuild' | 'webpack';
/** 注入环境变量 */
export function injectEnvironmentStr(context: ProxyParam) {
context[PRODUCT_BY_MICRO_FRONTEND] = true;
context.__vite_plugin_react_preamble_installed__ = true;
return true;
}
/** 使用import加载script */
export async function loadScriptByImport(scripts: string[]) {
injectEnvironmentStr(window);
let scriptStr = `
return Promise.all([`;
scripts.forEach((val) => {
scriptStr += `import("${val}"),`;
});
scriptStr = scriptStr.substring(0, scriptStr.length - 1);
scriptStr += `]);
`;
return await new Function(scriptStr)();
}
/** 执行js字符串 */
export async function loadScriptByString(
scripts: string[],
context: ProxyParam
) {
const scriptArr: Promise<Record<string, any>>[] = [];
injectEnvironmentStr(context);
scripts.forEach(async (val) => {
scriptArr.push(
await new Function(`
return (window => {
${val}
return window.middleVue;
})(this)
`).call(context)
);
});
return scriptArr;
}
/** 加载JS文件 */
export async function loadFunction<T extends LoadFunctionResult>(
context: Window,
scripts: string[] = [],
type: LoadScriptType = 'esbuild'
): Promise<T> {
let result = {};
if (type === 'esbuild') {
result = await loadScriptByImport(scripts);
} else {
result = await loadScriptByString(scripts, context);
}
let obj: LoadFunctionResult = {
beforeMount: () => {},
mount: () => {},
unmount: () => {},
};
(<Record<string, any>[]>result).forEach((val) => {
Object.assign(obj, val);
});
return <T>obj;
}
2. 定义构造函数(MicroFront)
上面我们已经,实现了获取HTML和运行JS文件的方法。接下来需要定义一个构造函数去调用它们。
注册应用时,我们需要传入以下参数的对象数组。
interface MicroFrontendMethod {
init: () => void;
setCurrentRoute: (routeName: string) => void;
start: () => void;
}
export default class MicroFrontend implements MicroFrontendMethod {
/** 微应用列表 */
private servers: RegisterData[];
/** 请求后的应用列表 */
private serverLoadData: Record<string, LoadHtmlResult>;
/** 当前路由 */
public currentRoute: string;
/** 当前开启的微应用容器 */
public currentActiveApp: string[];
/** 全局store */
public store: Record<string, any>;
constructor(servers: RegisterData[]) {
this.servers = servers;
this.serverLoadData = {};
this.currentRoute = '';
this.currentActiveApp = [];
this.store = createStore();
}
/** 初始化 */
public async init() {
for (let item of this.servers) {
const serverData = await loadHtml(item.entry, item.type);
addNewListener(item.appName);
this.serverLoadData[item.appName] = serverData;
}
return true;
}
/** 设置路由 */
public setCurrentRoute(routeName: string) {
const appIndex = this.servers.findIndex(
(val) => val.activeRoute === routeName
);
if (appIndex === -1) return false;
const appName = this.servers[appIndex].appName;
const isInclude = Object.keys(this.serverLoadData).includes(appName);
if (!isInclude) {
return false;
}
this.currentRoute = routeName;
return true;
}
/** 开启加载微前端应用 */
public async start() {
const currentRoute = this.currentRoute || window.location.pathname;
const appList = this.servers.filter(
(val) => val.activeRoute === currentRoute
);
for (let val of appList) {
const appName = val.appName;
const htmlData = this.serverLoadData[appName];
const scriptResult = await runScript(val, htmlData, this.store);
this.serverLoadData[appName].lifeCycle = scriptResult.lifeCycle;
this.serverLoadData[appName].sandbox = scriptResult.sandBox;
}
}
3. JS沙箱
沙箱的介绍已经有很多文件在介绍了,这里也就不多介绍,具体可以看这里
-
沙箱种类
iframe<iframe></iframe>
SnapshopSandbox(快照沙箱)
LegacySandbox(单例沙箱)
ProxySandbox(多例沙箱)
-
沙箱实现(多例沙箱)
interface SandBoxImplement { active: () => void; inActive: () => void; } type ProxyParam = Record<string, any> & Window; /** 沙箱操作 */ class SandBox implements SandBoxImplement { public proxy: ProxyParam; private isSandboxActive: boolean; public name: string; /** 激活沙箱 */ active() { this.isSandboxActive = true; } /** 关闭沙箱 */ inActive() { this.isSandboxActive = false; } constructor(appName: string, context: Window & Record<string, any>) { this.name = appName; this.isSandboxActive = false; const fateWindow = {}; this.proxy = new Proxy(<ProxyParam>fateWindow, { set: (target, key, value) => { if (this.isSandboxActive) { target[<string>key] = value; } return true; }, get: (target, key) => { if (target[<string>key]) { return target[<string>key]; } else if (Object.keys(context).includes(<string>key)) { return context[<string>key]; } return undefined; }, }); } } export default SandBox;
-
在沙箱中运行微应用
/** 注入环境变量 */ function injectEnvironmentStr(context: ProxyParam) { context[PRODUCT_BY_MICRO_FRONTEND] = true; context.__vite_plugin_react_preamble_installed__ = true; return true; } /** 执行js字符串 */ async function loadScriptByString( scripts: string[], context: ProxyParam ) { const scriptArr: Promise<Record<string, any>>[] = []; injectEnvironmentStr(context); scripts.forEach(async (val) => { scriptArr.push( await new Function(` return (window => { ${val} return window.middleVue; })(this) `).call(context) ); }); return scriptArr; }
4. 全局通信状态管理
到这里为止,我们已经可以在父应用中运行子应用了。但是很多时候需要再父应用和子应用之间进行数据通信
,或者子应用之间的数据通信
。
所以我们还需要实现一个全局的状态管理,在调用生命周期时,将状态管理方法传入微应用中。通信流程如下图所示,利用发布——订阅的方法通知订阅了参数改变事件触发对应的业务函数。
- 创建全局状态
/** 创建全局store */ export function createStore() { const globalStore = new Proxy(<Record<string, any>>{}, { get(target, key: string) { return target[key]; }, set(target, key: string, value) { const oldVal = target[key]; target[key] = value; // 触发监听事件 triggerEvent({ key, value, oldValue: oldVal }); return true; }, }); return globalStore; }
- 新增监听器
export type triggerEventParam<T> = { key: string; value: T; oldValue: T; }; /** 监听对象 */ const listener: Map< string, Record<string, (data: triggerEventParam<any>) => void> > = new Map(); /** 新增store监听器 */ export function addNewListener(appName: string) { if (listener.has(appName)) return; listener.set(appName, {}); }
- 订阅事件
/** 设置监听事件 */ export function setEventTrigger<T extends any>( appName: string, key: string, callback: (data: triggerEventParam<T>) => void ) { if (listener.has(appName)) { const obj = listener.get(appName); if (obj) { obj[key] = callback; } } }
- 触发事件
/** 改变字段值触发事件 */ export function triggerEvent<T extends any>(data: triggerEventParam<T>) { listener.forEach((val) => { if (val[data.key] && typeof val[data.key] === 'function') { val[data.key](data); } }); }
5. 路由监听
路由监听是微前端中非常重要且复杂的一环,我们需要通过监听路由变化去挂载或者卸载应用。我们都知道前端路由分为两种:hash
和history
路由。
监听hash很简单,只需要在监听器中定义hashchange
的方法即可。
监听history路由时,需要分成两种情况,触发浏览器前进,后退,我们可以使用popstate
方法监听到,但是history中的pushState
,replaceState
无法使用popstate
监听,我们需要重写这两个方法,将监听器放到这两个方法中。
- 监听hash路由变化回调
/** 监听hash路由变化 */ function listenHash(callback: listenCallback) { window.addEventListener('hashchange', (ev) => { callback(getHashPathName(ev.oldURL), getHashPathName(ev.newURL), {}); }); } function getHashPathName(url: string) { const pathArr = url.split('#'); return pathArr[1] ? `/${pathArr[1]}` : '/'; }
- 监听history路由变化回调
export type listenCallback = ( oldPathName: string, pathName: string, param: any ) => void; // 保存原生方法 const globalPushState = window.history.pushState; const globalReplaceState = window.history.replaceState; /** 监听history路由变化 */ function listenHistory(callback: listenCallback, currentRoute: string) { window.history.pushState = historyControlRewrite('pushState', callback); window.history.replaceState = historyControlRewrite('replaceState', callback); window.addEventListener('popstate', (ev) => { callback(currentRoute, window.location.pathname, ev.state); }); } // 重写pushState,replaceState方法 const historyControlRewrite = function ( name: 'pushState' | 'replaceState', callback: listenCallback ) { const method = history[name]; return function (data: any, unused: string, url: string) { const oldPathName = window.location.pathname; if (oldPathName === url) return; method.apply(history, [data, unused, url]); callback(oldPathName, url || '', data); }; };
6. 样式隔离
- 样式隔离解决方案
- 在微前端框架中获取style样式并添加唯一前缀
- 微应用中约定通过postcss处理
- 利用postcss处理样式隔离
演示
父应用我使用的是原生JS
,分别定义了一个vue
框架和react
框架的项目
Vue项目
React项目
运行
小结
源码地址: github.com/JeremyYu-cn…