微前端之自实现singleSpa|技术点评

1,223 阅读8分钟

前言

欲行其事,先明其理;要明白一个东西怎么运作的,就得先看它解决了什么问题,不然它不可能会出现且流传。

近年来,前端繁花似锦,能力提升的同时也带来了复杂度的提升,“拆分”,也就成了一个急需解决的key point

怎么拆,其实时至而今,已经有了很多围绕这个点的方案,比如动态路由动态引入、webpack中的多入口+@import拆分组件等等等,这些方案实现了一个项目下页面直接加载包过大的问题,但不同技术栈的应用间的拆就无能为力了,这时就出现了本文的主角:微前端

微前端,主要解决了技术栈的整合问题,前端技术栈繁多,假设一个场景:目前有两个应用,分别用vue和react实现的,我们希望能有个主站,同时管理应用A和应用B,即访问路由前缀为/A时将页面交由A项目进行管理,访问路由前缀为/B时交由项目B进行管理。

效果如下

QQ20210308-224542-HD

先看使用

篇幅较长,不加赘述,建议阅读下列文章

乾坤实战

singleSpa实战

解决思路

从上我们可以看出,我们需要一个工具,去控制多个应用的装载卸载替换等等逻辑,即实现一个应用的状态机

既然是一个状态机,很自然的,我们得从状态这一角度出发

  1. 定义状态
  2. 定义状态间的流转
  3. 定义状态机和应用之间的协议,即暴露的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,用以加载应用

  1. 将状态标为加载资源
  2. 执行用户传递过来的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的时候总结了三句话:

  • 获取引用:持有原方法
  • 实现接口:保持
  • 嵌入逻辑

前端没有接口的概念,转化记忆如下

  • 获取引用
  • 替换入口
  • 嵌入逻辑
  1. 获取引用
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
  1. 替换入口
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)
    }
}
  1. 嵌入逻辑

先执行状态机的路由更新,再执行其他应用监听逻辑

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提供了怎么做的能力,至此,微前端得以实现。

完整版代码的仓库地址,谢谢阅读

本文正在参与「掘金 2021 春招闯关活动」, 点击查看 活动详情