本文首发于 vivo互联网技术 微信公众号 
链接:https://mp.weixin.qq.com/s/2qH9qMNpU_LuLEBTsDUKzA
作者:Tan Xin

本文对微前端的概念和场景进行科普,介绍一些支流的微前端的实现库及其用法,并解说局部这些库的原理和实际常识。

一、微前端

在我的项目迭代中,随着业务的发展壮大,我的项目的功能模块通常也会越来越多。可能原来所有的代码模块都在一个仓库里,由一个团队负责。但随着功能模块越来越多,一个团队可能负责不过去,须要多个团队来专门保护不同的模块。相应的代码也会被拆到多个仓库里,并且各模块能独立开发、部署更新。通常尽管我的项目被拆成了多个模块,但为了维持整体统一性以及用户体验,各模块仍然都会挂在对立的入口下。

下面所述场景就是典型的微前端场景,相似于后端的微服务架构,它将web利用由繁多的单体利用转变为多个小型前端利用聚合为一的利用。

通常,要实现下面相似的需要,咱们很容易会想到应用iframe的形式来实现。在入口框架中用iframe来显示子模块的页面,切换子模块时,iframe也跟着切换成对应子模块页面的url。

尽管iframe是比拟容易实现的,但通常也会有一些问题:

  1. 显示区域受限制,比方子项目中显示弹窗蒙层时,蒙层只会笼罩iframe区域,无奈笼罩整个页面,内容也无奈真正居中。
  2. 页面浏览记录无奈主动被记录,刷新页面后iframe又主动回到首页。
  3. 全局上下文齐全隔离,变量不共享,页面间通信比拟麻烦,比方子项目与主题框架、子项目之间通信等,只能采纳postMessage形式。
  4. 速度较慢,每次进入子利用时都要重建整个上下文。

下面所列问题,有些能够解决,有些甚至都没法或者很难解决。总的来说,iframe是一个比拟快捷的计划,但不是最好的计划,会对体验有很多限度。如果强行打各种patch,复杂度又上来了,最初可能得失相当。

二、single-spa

方才咱们讲了iframe实现微前端的一些弊病,次要起因就是这些利用还是在各自独立的页面内,这就导致了一些人造的限度。而single-spa微前端计划联合了MPA和SPA的劣势,能够在单个页面内集成多个利用,并且是技术栈无关的。

如上图就是采纳single-spa实现微前端的整体流程:

资源模块加载器:用来加载子项目初始化资源。咱们将子项目的入口js构建成umd格局,而后应用模块加载器近程加载,通常会应用SystemJs(不是必须)通用模块加载器来进行加载。

子利用资源配置表:用来记录各个子利用的入口资源url信息,以便在切换不同子利用时应用模块加载器去近程加载。因为每次子利用更新后入口资源的hash通常会变动,所以须要服务端定时去更新该配置表,以便框架能及时加载子利用最新的资源。

留神:single-spa自身是不反对子利用资源列表的,每个子利用只能将本人所有初始化资源打包到一个入口js中。如果子利用初始化资源有多个文件(能够通过webpack-manifest-plugin生成利用初始化资源清单),就须要依照上述形式来增加额定解决。

1、框架入口

<!DOCTYPE html><html>  <head>  <!-- 在systemjs中注册模块 -->  <script type="systemjs-importmap">    {      "imports": {        "app1": "http://localhost:8081/js/app.js",        "app2": "http://localhost:8082/js/app.js",        "single-spa": "https://cdnjs.cloudflare.com/ajax/libs/single-spa/4.3.7/system/single-spa.min.js",        "vue": "https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js",        "vue-router": "https://cdn.jsdelivr.net/npm/vue-router@3.0.7/dist/vue-router.min.js",        "vuex": "https://cdnjs.cloudflare.com/ajax/libs/vuex/3.1.2/vuex.min.js"      }    }</script></head>  <body>  <div></div>  <!-- 加载systemjs -->  <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/system.min.js"></script>  <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/extras/amd.min.js"></script>  <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/extras/named-exports.js"></script>  <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/extras/named-register.min.js"></script>  <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/extras/use-default.min.js"></script>  <script>    (function () {      // 加载公共js库      Promise.all([System.import('single-spa'), System.import('vue'), System.import('vue-router'), System.import('vuex')]).then(function (modules) {        var singleSpa = modules[0];        var Vue = modules[1];        var VueRouter = modules[2];        var Vuex = modules[3];          Vue.use(VueRouter)        Vue.use(Vuex)          // single-spa注册子利用        singleSpa.registerApplication(          'app1',          () => System.import('app1'),          location => location.pathname.startsWith('/app1')        )          singleSpa.registerApplication(          'app2',          () => System.import('app2'),          location => location.pathname.startsWith('/app2')        )          // 启动        singleSpa.start();      })    })()</script></body>  </html>

为了简略展现,上述只是框架入口html的一个简略demo,并没有解析子利用资源配置表来加载相应资源。在入口中咱们注册了子利用,并确定了子利用的激活机会。

子利用资源配置表是齐全自定义的,只有入口加载器这边依照约定的标准来解析加载资源,并依照single-spa的生命周期钩子来解决好这些资源的挂载。

咱们还能够将一些公共的资源库资源库(如上vue、vue-router等)抽取到入口中,这样各个子利用不须要再蕴含这些库文件了,能够减小资源文件大小,晋升加载速度。子利用中构建时要外置这些库,比方用webpack构建时如下:

externals: ['vue', 'vue-router', 'vuex']

2、子利用入口

import './set-public-path'import Vue from 'vue'import App from './App.vue'import router from './router'import singleSpaVue from 'single-spa-vue'  Vue.config.productionTip = false  if (process.env.NODE_ENV === 'development') {  // 开发环境间接渲染  new Vue({    router,    render: h => h(App)  }).$mount('#app')}  const vueLifecycles = singleSpaVue({  Vue,  appOptions: {    render: (h) => h(App),    router  }})  export const bootstrap = vueLifecycles.bootstrapexport const mount = vueLifecycles.mountexport const unmount = vueLifecycles.unmount

如上咱们的子利用是vue开发的,须要用single-spa-vue来包装下,而后导出生命周期的钩子函数。为了不便开发,咱们能够判断下运行环境,如果是开发环境的话,就间接渲染到页面上。

set-public-path.js

仔细的同学就会留神到,子利用代码中运行了set-public-path.js。那么这个文件是干嘛用的呢?先来看下:

import { setPublicPath } from 'systemjs-webpack-interop'setPublicPath('app1', 2)

从名字也能看出,systemjs-webpack-interop是针对在systemjs中应用webpack构建的bundle的场景的。家喻户晓,webpack构建代码时,能够通过output.publicPath选项指定要加载资源的url前缀,这在传统的spa中不会有问题,但在single-spa的页面中可能会有问题。比方output.publicPath: '/xx'的状况,webpack会认为异步资源加载的url域名为以后页面的域名,这在传统spa中不会有问题,但在single-spa的场景下异步资源就会加载失败,因为子利用的异步资源与框架页面的url域名并不是一样的。所以须要各个子利用自行在入口中执行上述代码,这会设置子利用的异步资源url前缀与子利用的入口js统一,这样加载的门路就不会谬误了。

setPublicPath代码如下:

export function setPublicPath(systemjsModuleName, rootDirectoryLevel) {  if (!rootDirectoryLevel) {    rootDirectoryLevel = 1;  }    if (    typeof systemjsModuleName !== "string" ||    systemjsModuleName.trim().length === 0   ) {     throw Error(      "systemjs-webpack-interop: setPublicPath(systemjsModuleName) must be called with a non-empty string 'systemjsModuleName'"     );   }    if (    typeof rootDirectoryLevel !== "number" ||    rootDirectoryLevel <= 0 ||    !Number.isInteger(rootDirectoryLevel)  ) {     throw Error(      "systemjs-webpack-interop: setPublicPath(systemjsModuleName, rootDirectoryLevel) must be called with a positive integer 'rootDirectoryLevel'"    );   }    let moduleUrl;  try {    moduleUrl = window.System.resolve(systemjsModuleName);    if (!moduleUrl) {      throw Error()     }    } catch (err) {     throw Error(      "systemjs-webpack-interop: There is no such module '" +        systemjsModuleName +        "' in the SystemJS registry. Did you misspell the name of your module?"    );    }   __webpack_public_path__ = resolveDirectory(moduleUrl, rootDirectoryLevel); } function resolveDirectory(urlString, rootDirectoryLevel) {  const url = new URL(urlString);  const pathname = new URL(urlString).pathname;  let numDirsProcessed = 0,    index = pathname.length;   while (numDirsProcessed !== rootDirectoryLevel && index >= 0) {    const char = pathname[--index];    if (char === "/") {      numDirsProcessed++;    }  }   if (numDirsProcessed !== rootDirectoryLevel) {    throw Error(      "systemjs-webpack-interop: rootDirectoryLevel (" +        rootDirectoryLevel +        ") is greater than the number of directories (" +        numDirsProcessed +        ") in the URL path " +        fullUrl    );   }   url.pathname = url.pathname.slice(0, index + 1);  return url.href; }

三、single-spa的有余

  1. 如下面提到过,如果子利用初始化资源有多个文件(比方通常咱们会将css、npm模块抽离成一个独自的文件),那么咱们就要自行保护一个子利用资源列表并做一些额定解决,这个工作往往也是比拟繁琐的;
  2. 将多个子利用都集成在一个页面中,css和js都是很有可能产生抵触的。尽管咱们能够制订标准,比方各子项目应用惟一地命名前缀等,但这种人为约定往往又是不那么靠谱。对于css,咱们还能够在构建时应用一些工具主动增加前缀,这样能够比拟靠谱的防止抵触;对于js来说,比拟靠谱的形式可能就是人为制作沙箱,让子利用的js都运行在各自的沙箱中,但这实现起来就比较复杂了。

四、qiankun

其实,曾经有个基于single-spa的开源库qiankun曾经帮咱们解决了下面提到的问题,其有如下特色:

  • 解析子利用入口时,不是解析的js文件,二是间接解析子利用的html文件。就算子利用更新了,其入口html文件的url始终不会变,并且残缺的蕴含了所有的初始化资源url,所以不必再自行保护子利用的资源列表了。
  • 子利用挂载时,会主动进行一些非凡解决,能够确保子利用所有的资源dom(包含js增加的style标签等)都集中在子利用根节点dom下。子利用卸载时,对应的整个dom都移除了,这样也就防止了款式抵触。
  • 提供了js沙箱,子利用挂载时,会对全局window对象代理、对全局事件监听进行劫持等,确保各子利用都运行在本人的沙箱内,这样也就防止了js抵触。

蕴含多个spa利用的demo

子利用 dom 构造如下

当然,在前端越来越宏大简单的场景中,微前端计划也不是银弹,但确是值得摸索实际的方向。

五、参考文献

  1. single-spa
  2. qiankun
  3. 可能是你见过最欠缺的微前端解决方案

更多内容敬请关注 vivo 互联网技术 微信公众号

注:转载文章请先与微信号:Labs2020 分割。