最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 微前端之自实现singleSpa

    正文概述 掘金(南方小菜)   2021-03-11   829

    前言

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

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

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

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

    效果如下

    微前端之自实现singleSpa

    先看使用

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

    乾坤实战

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

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


    起源地下载网 » 微前端之自实现singleSpa

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元