前言
欲行其事,先明其理;要明白一个东西怎么运作的,就得先看它解决了什么问题,不然它不可能会出现且流传。
近年来,前端繁花似锦,能力提升的同时也带来了复杂度的提升,“拆分”,也就成了一个急需解决的key point
;
怎么拆,其实时至而今,已经有了很多围绕这个点的方案,比如动态路由
、动态引入
、webpack中的多入口+@import
、拆分组件
等等等,这些方案实现了一个项目下页面直接加载包过大的问题,但不同技术栈的应用间的拆就无能为力了,这时就出现了本文的主角:微前端
微前端,主要解决了技术栈的整合问题,前端技术栈繁多,假设一个场景:目前有两个应用,分别用vue和react实现的,我们希望能有个主站,同时管理应用A和应用B,即访问路由前缀为/A
时将页面交由A项目进行管理,访问路由前缀为/B
时交由项目B进行管理。
效果如下
先看使用
篇幅较长,不加赘述,建议阅读下列文章
乾坤实战
singleSpa实战
解决思路
从上我们可以看出,我们需要一个工具,去控制多个应用的装载
、卸载
、替换
等等逻辑,即实现一个应用的状态机
。
既然是一个状态机,很自然的,我们得从状态
这一角度出发
- 定义
状态
- 定义状态间的流转
- 定义状态机和应用之间的协议,即暴露的API,从而状态机通过这些协议进行对应用的状态控制
微前端规定
协议
父应用
需要进行应用注册registerApplication
和启动start
// 默认先加载应用 在调用start方法时进行挂载应用
// 1. 注册应用名 2. 返回一个promise的函数 函数返回值为一个对象,包含三个函数 bootstrap mount unmount
singleSpa.registerApplication('app1',async () => {
return {
bootstrap: async () => {
},
mount: async () => {
},
unmount: async () => {
}
}
},
location => location.hash.startsWith('#/app1'),
{
store: {
name: '11',
age: 1
}
}
)
// 挂载应用
singleSpa.start();
子应用
需暴露三个协议接口 逻辑自行实现 状态机会在对应状态时执行对应函数
bootstrap: async () => {
// 启动
},
mount: async () => {
// 挂载
},
unmount: async () => {
// 卸载
}
状态图
graph TB
A[NOT_LOADED 没加载过] --> B[LOADING_SOURCE_CODE 加载资源] --> C[NOT_BOOTSTRAPPED 没启动过] --> D[BOOTSTRAPPING 启动中] --> E[NOT_MOUNTED 没有挂载] --> F[MOUNTING 挂载中] --> G[MOUNTED] --挂载完成--> H[UPDATING]
B --> I[LOAD_ERR 资源加载失败]
H --更新--> G
O[BECAUSE_BROKEN 代码出错]
style O fill:red
style I fill:red
具体实现
状态机singleSpa
接管所有应用
在主应用中,通过registerApplication
,进行应用注册,从而状态机接管所有应用
const apps = []; // 存储所有的应用
/**
* 维护应用所有的状态
* @param {*} appName 应用名字
* @param {*} loadApp 加载应用
* @param {*} activeWhen 当激活时会调用 loadApp
* @param {*} customProps 自定义属性
*/
export function registerApplication(
appName,loadApp,activeWhen,customProps
) {
apps.push({
name: appName,
loadApp,
activeWhen,
customProps,
status: NOT_LOADED,
})
reroute(); // 加载应用
}
定义状态(对照上面的状态流程图)
export const NOT_LOADED = 'NOT_LOADED'; // 应用初始状态
export const LOADING_SOURCE_CODE = 'LOADING_SOURCE_CODE'; // 加载资源
export const NOT_BOOTSTRAPPED = 'NOT_BOOTSTRAPPED'; // 还没有调用bootstrap方法
export const BOOTSTRAPPING = 'BOOTSTRAPPING'; // 启动中
export const NOT_MOUNTED = 'NOT_MOUNTED'; // 还没有调用mount方法
export const MOUNTING = 'MOUNTING'; // 挂载中
export const MOUNTED = 'MOUNTED'; // 挂载完毕
export const UPDATING = 'UPDATING'; // 更新中
export const UNMOUNTING = 'UNMOUNTING'; // 解除挂载
export const LOAD_ERR = 'LOAD_ERR'; // 加载错误
export const SKIP_BECAUSE_BROKEN = 'SKIP_BECAUSE_BROKEN'; // 代码错误
加载应用
接下来,状态机需要去加载应用,考虑到后面路由切换也会进行应用的重新加载,定义方法reroute
实现应用加载的逻辑
此时就需要区分逻辑了,判断是初次加载还是路由切换
- 初次加载:需要预加载所有应用
- 路由切换:关注三点
- 获取需要加载的应用
- 获取需要被挂载的应用
- 哪些应用需要被卸载
// 如果是已经启动的状态
if (started) {
// app装载
console.log('调用start');
return performAppChanges();
}
// 如果是初次加载
else{
// 注册应用时 需要预加载
console.log('调用register');
return loadApps();
}
接下来,就是实现路由切换逻辑的performAppChange
和预加载的loadApp
了
初次加载
首先,我们先根据应用的状态将所有应用分为三类
const appsToUnmount = []; // 需要卸载的应用
const appsToLoad = []; // 需要加载的app
const appsToMount = []; // 需要挂载的应用
状态默认都是NOT_LOADED
;当匹配函数命中(即返回值为true)时,其应用应该存入appsToLoad
中;定义函数shouldBeActive
判断是否命中;
// 当前应用是否需要激活
export function shouldBeActive(app) {
return app.activeWhen(window.location);
}
定义方法getAppChanges
,用于为上面的三个数组赋值
// 获取不同状态的应用队列
export function getAppChanges() {
const appsToUnmount = []; // 需要卸载的应用
const appsToLoad = []; // 需要加载的app
const appsToMount = []; // 需要挂载的应用
apps.forEach(app=>{
const appShouldBeActive = shouldBeActive(app);
switch (app.status) {
case NOT_LOADED:
case LOADING_SOURCE_CODE:
if(appShouldBeActive){
appsToLoad.push(app);
}
break;
case NOT_BOOTSTRAPPED:
case BOOTSTRAPPING:
case NOT_MOUNTED:
if (appShouldBeActive) {
appsToMount.push(app);
}
break;
case MOUNTED:
if (!appShouldBeActive) {
appsToUnMount.push(app)
}
default:
break;
}
})
return {
appsToLoad,
appsToMount,
appsToUnmount
}
}
这个时候,应用就分类完成了;我们可以开始我们的核心逻辑:加载应用。
预加载
初次加载时,我们只需要预加载应用,其实就是获取到bootstrap mount unmount
方法 放在状态机托管的应用对象上,此处定义方法toLoadPromise
,用以加载应用
- 将状态标为加载资源
- 执行用户传递过来的loadApp并把自定义参数传递进去,获得返回的生命周期函数(可能会是一个数组,加一层数租拍平逻辑)
function toLoadPromise(app) {
// 缓存 如果已经加载了 就直接返回
if (app.loadPromise) {
return app.loadPromise;
}
// 如果未加载 1. 将状态标为加载资源 2. 执行用户传递过来的loadApp并把自定义参数传递进去,获得返回的生命周期函数(可能会是一个数组,加一层数租拍平逻辑
return (app.loadPromise = Promise.resolve().then(async ()=>{
app.status = LOADING_SOURCE_CODE;
// 注意父应用和子应用的传参
let {bootstrap,mount,unmount} = await app.loadApp(app.customProps);
app.status = NOT_BOOTSTRAPPED; // 还没有调用bootstrap方法
// 可能是一个数组 所以需要考虑数组拍平的情况
app.bootstrap = flattenFnArray(bootstrap);
app.mount = flattenFnArray(mount);
app.unmount = flattenFnArray(unmount);
delete app.loadPromise;
return app;
}))
}
处理所有需加载应用(也就是命中的应用)
// 预加载应用
async function loadApps() {
const apps = await Promise.all(appsToLoad.map(toLoadPromise))
}
此时,命中的应用的状态变成了LOADING_SOURCE_CODE
,存储在了appsToLoad
数租中,且身上有了bootstrap mount unmount
三个生命周期函数。就完成了应用的预加载。
路由切换
如何卸载
卸载不需要的应用,定义卸载函数toUnmountPromise
function toUnmountPromise(app) {
// 如果当前应用没有被挂载 则不做处理
if (app.status != MOUNTED) {
return app;
}
app.status = UNMOUNTING;
await app.unmount(app.customProps);
app.status = NOT_MOUNTED;
return app;
}
如何装载
// 将需要加载的应用拿到 加载 - 启动 - 挂载
appsToLoad.map(async (app)=>{
app = toLoadPromise(app);
app = await toBootstrapPromise(app);
return await toMountPromise(app);
})
其中的状态改变函数如下
import { BOOTSTRAPPING, NOT_BOOTSTRAPPED, NOT_MOUNTED } from "../applications/app.helper";
export async function toBootstrapPromise(app) {
if(app.status !== NOT_BOOTSTRAPPED){
return app;
}
app.status = BOOTSTRAPPING;
await app.bootstrap(app.customPorps);
app.status = NOT_MOUNTED;
return app;
}
export async function toLoadPromise(app) {
if (app.loadPromise) {
return app.loadPromise;
}
return (app.loadPromise = Promise.resolve().then(async ()=>{
app.status = LOADING_SOURCE_CODE;
// 注意父应用和子应用的传参
let {bootstrap,mount,unmount} = await app.loadApp(app.customProps);
app.status = NOT_BOOTSTRAPPED; // 还没有调用bootstrap方法
// 可能是一个数组 所以需要考虑数组拍平的情况
app.bootstrap = flattenFnArray(bootstrap);
app.mount = flattenFnArray(mount);
app.unmount = flattenFnArray(unmount);
delete app.loadPromise;
return app;
}))
}
export async function toMountPromise(app) {
if(app.status !== NOT_MOUNTED){
return app;
}
app.status = MOUNTING;
await app.mount(app.customPorps);
app.status = MOUNTED;
return app;
}
最后,清空下欲挂载数组appsToMount
// 挂载
appsToMount.map(async app => {
app = toBootstrapPromise(app);
return toMountPromise(app);
})
至此,我们完成了状态机非初次的加载时的处理逻辑;知道了怎么做,那就得解决在什么时候做的问题
什么时候进行应用切换
那必然是路由切换时,而想要在路由改变时先执行状态机的逻辑(即卸载旧应用,装载新应用),关键点在于
- 前端路由(hashchange、popstate)
- 浏览器路由(history.pushState、history.replaceState)
的重载,因为要保证状态机的逻辑先于其他监听者的逻辑执行,比如子应用时Vue的话,vue路由的实现也是监听上面两个路由,如果不保证顺序,则会出现父子路由混乱。
要实现重载,自然想到AOP
的思想,即面向切面(如不了解,请移步强大的AOP)
干java的时候总结了三句话:
- 获取引用:持有原方法
- 实现接口:保持
- 嵌入逻辑
前端没有接口的概念,转化记忆如下
- 获取引用
- 替换入口
- 嵌入逻辑
- 获取引用
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
- 替换入口
export const routingEventsListeningTo = ['hashchange','popstate'];
// 暂存钩子其他的监听事件 执行完状态机的逻辑后再执行
const captureEventListeners = {
hashchange: [],
popstate: []
}
window.addEventListener = function (eventName,fn) {
if (routingEventsListeningTo.indexOf(eventName) >= 0 && ! captureEventListeners[eventName].some(listener=>listener == fn) ) {
captureEventListeners[eventName].push(fn);
return;
}else {
return originalAddEventListener.apply(this,arguments);
}
}
window.removeEventListener = function (eventName,fn) {
if (routingEventsListeningTo.indexOf(eventName) >= 0) {
captureEventListeners[eventName] = captureEventListeners[eventName].filter(l => l !== fn);
}else {
originalRemoveEventListener.apply(this,arguments)
}
}
- 嵌入逻辑
先执行状态机的路由更新,再执行其他应用监听逻辑
async function urlReroute() {
// 根据路径 重新加载不同的应用
await reroute([],arguments);
// 执行其他应用监听逻辑
captureEventListeners.hashchange.forEach(fn=>fn());
captureEventListeners.popstate.forEach(fn=>fn());
}
window.addEventListener('hashchange',urlReroute);
window.addEventListener('popstate',urlReroute);
对于浏览器路由(history.pushState、history.replaceState)也是相似逻辑
// 浏览器路由(history.pushState、history.replaceState)
function patchedUpdateState(updateState,methodName) {
return function () {
const urlBefore = window.location.href;
updateState.apply(this,arguments); // 调用切换方法
const urlAfter = window.location.href;
// 如果路由发生改变 则执行页面更新逻辑
if (urlAfter !== urlBefore) {
urlReroute(new PopStateEvent('popstate'));
}
}
}
window.history.pushState = patchedUpdateState(window.history.pushState,'pushState');
window.history.replaceState = patchedUpdateState(window.history.replaceState,'replaceState');
至此,我们就完成了路由监听,路由改变时状态机会对应的进行装载和卸载
总结
到这里,微前端框架的大体实现也就告一段落了,总结而言其实就是:
微前端的出现意义在于抹平应用之间如不同技术栈这类的差异,让一个承接的主项目中不再关系注册的子应用具体细节的不同,而只关心多个子应用状态的控制。它并非银弹,实现也不复杂,其实是一个顺理成章出现的产品,因为SPA,所以出现了all in js
的思想,一个应用变成了几个js文件加一个dom挂载点,而all in js
恰恰是微前端的生存土壤,前端路由提供了什么时候做
的能力,all in js
提供了怎么做
的能力,至此,微前端得以实现。
完整版代码的仓库地址,谢谢阅读
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!