微前端扫盲落地篇

·  阅读 592

coding.jpg

本文主要介绍一些微前端的基础知识以及微前端框架single-spa的简单使用与真实落地场景。

1. 微前端的基础知识

什么是微前端

微前端是一种架构方式,核心思想就是 分而自治,将一个超级大的应用拆分成若干个小的解耦自治应用。

微前端的优势(为什么用)

  • 各个应用独立开发,测试,部署
    各个应用拥有独立的git工程,独立的开发/测试团队,独立的构建部署流程,互不影响。
  • 自由的技术栈
    各个应用可以采用不同的技术栈。
  • 天然就是懒加载
    子应用的js和css只有当子应用被挂载时,才会被下载执行。

微前端的适用场景(什么时候用)

  • 随着时间的推移,系统变得越来越庞大,难以维护,往往改一处而动全身,带来的上线风险不可控;构建/部署时间难以忍受等等。
  • 新的系统需要整合旧系统的功能,旧系统技术古老,又不想重构或者没有时间人力重构。
  • 某一个系统应用同时由很多个团队进行维护开发。
  • 想尝试不同的技术栈。

微前端带来的问题(使用时需要注意的问题)

微前端应用通常由两部分构成,一个是我们拆分出来的 子应用(正常的spa项目) ,而另一个就是负责整合这些子应用的 基座应用(负责资源加载与子应用的调度)。 由于这些应用共用一个运行时,所以必然会带来一些问题。

  • 共享依赖

    • 抽离npm包
      通常我们对于各个项目中的通用逻辑/UI都会进行抽象封装,然后在另一个git仓库中进行维护,然后发布 npm 包;其他项目安装npm包来进行使用。
    • 打包external + CDN
      举个例子,我们每个子应用采用的都是vue的技术栈,那么子应用进行构建打包的时候就可以把vue/vue- router排除在外,这样就减少了子应用打包后的体积;然后在基座应用中引入这些vue/vue-router依赖的CDN,保证这些依赖只被加载了一次;当然前提是这些通用依赖的库版本最好保持一致
    • 依赖单独打包
      还有一种情况,我们的子应用采用的不同的技术栈或者同样的技术栈但是版本不一致,那么此时我们应该如何 处理呢?答案当然就是每个子应用还是正常构建打包,不用 external 。因为此时 external 没有意义,相当于子应用之间没有公共的依赖。
  • 样式冲突
    这不是一个新问题,只不过在微前端的架构模式中,该问题变得更严重了。那么如何解决呢?答案就是样式需要隔离,互不干扰。我们这里的样式冲突主要分为两种:一种是子应用之间的冲突,一种是基座应用与子应用之间的冲突。

    • 子应用之间的冲突
      对于这种冲突来说,解决比较简单,只要在应用卸载的时候,将对应的link标签与style标签删除即可。
    • 基座应用与子应用之间的冲突
      对于这种冲突来说,可以采用CSS Module 或者命名空间的方式,给每个微应用模块以特定前缀,即可保证不会互相干扰,可以采用webpack的postcss插件。
  • js隔离
    由于微前端是共用运行时,所以很可能会造成全局变量污染等问题,为此,需要在加载和卸载每个子应用的同时,尽可能消除这种冲突和影响,最普遍的做法是采用 规范约定沙箱机制

  • 应用通信
    应用之间的通信是微前端绕不开的一个问题,虽然我们已经将应用拆分的足够解耦,但是还是会遇到应用之间需要通信的场景。通常来讲我们会通过中间媒介或者说全局对象来进行通信。

    • 利用自定义事件
      比如利用 window.dispatchEvent 触发事件,window.addEventListener 监听事件。
    • 利用浏览器的地址栏作为通信桥梁
    • 利用一些全局对象

微前端的实现方案(怎么用)

方案优点缺点
iframe天然支持隔离,开发者不需关注隔离问题
接入简单
可以同时存在多个子应用挂载
浏览器刷新 iframe url 状态丢失
弹框只能展示在iframe区域
通信困难,只能通过postMessage
...
single-spa提供cli工具生成基座应用/微应用/微模块/微组件
支持尽可能多的框架和构建工具,并提供对应改造工具
js,css隔离需要开发者自行处理
接入有一定的成本
qiankun基于single-spa,提供了更加开箱即用的API
HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单
样式隔离,确保微应用之间样式互相不干扰
JS 沙箱,确保微应用之间 全局变量/事件 不冲突
资源预加载,在浏览器空闲时间预加载未打开的微应用资源
共享依赖不方便,导致依赖可能会出现重复,使得出现体积变大
不支持ssr
emp基于Webpack5的新特性Module Federation实现,达到第三方依赖共享
按需加载,开发者可以选择只加载微应用中需要的部分,而不是强制只能将整个应用全部加载
支持ssr
远程拉取ts声明文件
状态共享极其方便
依赖webpack5

如何动态加载子应用

子应用提供什么形式的资源作为渲染入口?目前有两种方案,HTML entry 与 Js entry

  • Js entry
    子应用将资源打成一个entry script,包括css等;弊端就是打出来的包体积庞大,资源的并行加载等特性无法利用;如single-spa的cli工具生成的vue项目就是这样的。
  • HTML entry 将子应用打出来 HTML 作为入口,主框架可以通过 fetch html 的方式获取子应用的静态资源,通过一大堆正则获取到对应的js,css,入口脚本等。这样不仅可以极大的减少主应用的接入成本,子应用的开发方式及打包方式基本上也不需要调整。
    当然我们也可以直接将子应用的静态资源直接作为json文件提供出来,这种被称为Config entry,也属于HTML entry。

2. single-spa的简单使用

Single-spa 是一个将多个单页面应用聚合为一个整体应用的 JavaScript 微前端框架。

  • 兼容性
    "browserslist": [
        "ie >= 11",
        "last 4 Safari major versions",
        "last 10 Chrome major versions",
        "last 10 Firefox major versions",
        "last 4 Edge major versions"
    ]
    复制代码
  • 核心API
    • 基座应用代码
      import { registerApplication, start } from "single-spa";
      registerApplication({
          name: "vue-app1",
          app: () => System.import("./app1.js"),
          activeWhen: "/app1",
          customProps: {
              title: 'app1'
          }
      });
      start({ urlRerouteOnly: true });
      // name: string 应用的名字
      // app: Application | () => Application | Promise<Application>
      // activeWhen: string | (location) => boolean | (string | (location) => boolean)[]
      // 应用的激活条件
      // customProps?: Object = {} 自定义属性,生命周期钩子函数执行时会被作为参数传入
      复制代码
    • 子应用
      export function bootstrap(props) {
          // 这个生命周期函数会在应用第一次挂载前执行一次。
          console.info("bootstrap", props);
          return Promise.resolve().then(() => {
              console.info("bootstrap");
          });
      }
      export function mount(props) {
          // 每当应用路由匹配成功,但该应用处于未挂载状态时,挂载的生命周期函数就会被调用。
          // 调用时,函数会根据URL来确定当前被激活的路由,创建DOM元素、监听DOM事件等以向用户呈现渲染的内容。
          // 任何子路由的改变(如hashchange或popstate等)不会再次触发mount,需要各应用自行处理。
          console.info("mount", props);
          return Promise.resolve().then(() => {
              document.getElementById("app").innerHTML = `我是${props.title}啊`;
              window.app1 = 'app1';
          });
      }
      export function unmount(props) {
          // 每当应用路由匹配不成功,但该应用已挂载时,卸载的生命周期函数就会被调用。
          // 卸载函数被调用时,会清理在挂载应用时被创建的DOM元素、事件监听、内存、全局变量和消息订阅等。
          console.info("unmount", props);
          return Promise.resolve().then(() => {
              document.getElementById("app").innerHTML = "";
              window.app1 = undefined;
          });
      }
      export function unload(props) {
          // 移除生命周期函数的实现是可选的,它只有在unloadApplication被调用时才会触发。如果一个已注册的应用没有实现这个生命周期函数,则假设这个应用无需被移除。
          // 移除的目的是各应用在移除之前执行部分逻辑,一旦应用被移除,它的状态将会变成NOT_LOADED,下次激活时会被重新初始化。
          // 移除函数的设计动机是对所有注册的应用实现“热下载”,不过在其他场景中也非常有用,比如想要重新初始化一个应用,且在重新初始化之前执行一些逻辑操作时。
          return Promise.resolve().then(() => {
              console.info("unload", props);
          });
      }
      复制代码
      以上就是对于single-spa的最简单使用,基座应用负责注册子应用,子应用负责实现 bootstrapmountunmount 这三个生命周期函数。
      实际开发中我们并不需要自己去实现这三个生命周期函数,single-spa已经帮我们做了这些工作。zh-hans.single-spa.js.org/docs/ecosys…
      以vue为例
      import { h, createApp } from "vue";
      import singleSpaVue from "single-spa-vue";
      import App from "./App.vue";
      import router from "./router";
      const vueLifecycles = singleSpaVue({
          createApp,
          appOptions: {
              render() {
                  return h(App, {
                      name: this.name,
                  });
              },
              el: '#app'
          },
          handleInstance(app) {
              app.use(router);
          },
      });
      export const bootstrap = vueLifecycles.bootstrap;
      export const mount = vueLifecycles.mount;
      export const unmount = vueLifecycles.unmount;
      复制代码

3. 真实落地场景

  • 对于子应用进行改造
    • 使用 webpack-assets-plugin 构建生成json文件,描述js,css资源,格式如下
    {
        "css": [
            "xxx.css",
            "yyy.css"
        ],
        "js": [
            "xxx.js",
            "yyy.js"
        ]
    }
    复制代码
    • 以umd格式打包
      基座应用需要获取到子应用暴露出的一些钩子引用,如 bootstrapmountunmount;
      最简单的解法就是与子应用与基座应用之间约定好一个全局变量(比如是子应用的项目名称),把导出的钩子引用挂载到这个全局变量上,然后基座应用从这里面取生命周期函数。
      子应用需要对打包方式进行调整,调整如下
      configureWebpack: {
          output: {
              library: name,
              libraryTarget: 'umd',
              jsonpFunction: `webpackJsonp_${packageName}`,
          },
      }
      // name 为子应用项目名称,此时window上就有一个`${name}`属性
    复制代码
  • 基座应用的处理
    • 如何获取子应用的js,css
      基座应用有一份json文件,用来维护各个子应用
      {
          "vue-app1": {
              "path": "va1"
          },
          "vue-app2": {
              "path": "va2"
          },
      }
      // key值就是子应用的激活条件,即子应用的base路由
      // path用来转发
      复制代码
      基座应用需要根据上述json文件去请求每个子应用输出的的assets.json,然后拿到对应的js,css资源。
        import slaveMapping from './slave-mapping.json';
        async function resolveAppsFromMenu() {
            const avaliableApps: string[] = Object.keys(slaveMapping);
            const apps = await Promise.all(
                avaliableApps.map(appName => {
                    const timestamp = Date.now();
                    const appDir = slaveMapping[appName].path;
                    return (fetch.get(`/${appDir}/assets.json?_t=${timestamp}`)
                        .then((res) => {
                            const exports = res.data;
                            return {
                                name: appName,
                                css: exports.css,
                                js: exports.js,
                            };
                        })
                    )
                })
            );
            return apps
        }
      复制代码
    • 如何注册子应用
      import { registerApplication, start } from "single-spa";
      async function load() {
         const apps = await this.resolveAppsFromMenu();
         await registerApps(apps, { user: this.userInfo })
      }
      function registerApps(apps, customProps) {
         for(const app of apps) {
             const { name } = app;
             registerApplication({
                 name,
                 app: async () => {
                     lifecycle = await loadApp(app);
                     const { mount, unmount, ...otherMicroAppConfigs } = lifecycle;
                     return {
                         mount: [async (): Promise<any> => await loadStyles(app), mount],
                         unmount: [unmount, async (): Promise<any> => await unloadStyles(app)],
                         ...otherMicroAppConfigs,
                     }
                 },
                 activeWhen: (location) => new RegExp(`^/${name}\\b`).test(location.pathname),
                 customProps,
             })
         }
      }
      复制代码
    • loadApp 返回值为什么可以解构出 mountunmount,等生命周期函数;
      async function loadApp(app) {
         const { js, css } = app;
         // 加载js,之后在window上会挂载对应的属性,如winodw.vue-app1
         await fetchAppScripts(js);  
         // entryName就是挂载在window上的属性,如winodw.vue-app1
         const entryName = getGlobalProp(); 
         return window[entryName]   // 返回值拥有对应的生命周期钩子
      }
      复制代码
    • 如何解决样式冲突
      我们可以看到在上述加载子应用的时候,在 mount 阶段我们加载了对应应用的样式,而在 unmount 阶段我们卸载了对应的样式
      async function loadStyles(app) {
         await Promise.all(app.css.map(style => loadStyle(style)));
      }
      // 加载样式
      async function loadStyle(src) {
         const loadingPromise: Promise<void> = new Promise((resolve, reject) => {
             const linkNode: HTMLLinkElement = document.createElement('link');
             linkNode.rel = 'stylesheet';
             linkNode.type = 'text/css';
             let load: (() => void) | null = null;
             let error: (() => void) | null = null;
             load = (): void => {
                 linkNode.removeEventListener('load', load!);
                 linkNode.removeEventListener('error', error!);
                 resolve();
             };
             error = (): void => {
                 linkNode.removeEventListener('load', load!);
                 linkNode.removeEventListener('error', error!);
                 reject();
             };
             linkNode.addEventListener('load', load);
             linkNode.addEventListener('error', error);
             linkNode.href = src;
             document.head.appendChild(linkNode);
         });
         return loadingPromise;
      }
      // 卸载样式
      function unloadStyles(app: LoadableApp): void {
         app.css.forEach(css => {
             const links = document.querySelectorAll(`link[href="${css}"]`);
             links.forEach(link => {
                 if (link && link.parentNode) {
                     link.parentNode.removeChild(link);
                 }
             });
         });
      }
      复制代码

参考文章

分类:
前端
分类:
前端
收藏成功!
已添加到「」, 点击更改