微前端实践二

微前端架构之single-spa

single-spa是什么

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

  • 在同一页面上使用多个前端框架 而不用刷新页面 (React, AngularJS, Angular, Ember, 你正在使用的框架)
  • 独立部署每一个单页面应用
  • 新功能使用新框架,旧的单页应用不用重写可以共存
  • 改善初始加载时间,迟加载代码

single-spa做了什么

single-spa是一个顶层路由。当路由处于活动状态时,它将下载并执行该路由下的相关代码。

路由的代码被称为应用,每个代码都可以(可选)拥有自己的git仓库、CI进程,并且可以独立部署。这些应用即可以用相同框架实现,也可以用不同框架实现。

single-spa包括些什么:

  • 1、Applications,每个应用程序本身就是一个完整的 SPA (某种程度上)。 每个应用程序都可以响应 url 路由事件,并且必须知道如何从 DOM 中初始化、挂载和卸载自己。 传统 SPA 应用程序和 Single SPA 应用程序的主要区别在于,它们必须能够与其他应用程序共存,而且它们没有各自的 html 页面

例如,React 或 Vue spa 就是应用程序。 当激活时,它们监听 url 路由事件并将内容放在 DOM上。 当它们处于非活动状态时,它们不侦听 url 路由事件,并且完全从 DOM 中删除。

  • 一个 single-spa-config配置, 这是html页面和向Single SPA注册应用程序的JavaScript。每个应用程序都注册了三件东西
    • A name (应用的标识)
    • A function (加载应用程序的代码)
    • A function (确定应用程序何时处于活动状态/非活动状态)

single-spa的使用方式

Single-spa 适用于 ES5、 ES6 + 、 TypeScript、 Webpack、 SystemJS、 Gulp、 Grunt、 Bower、 ember-cli 或 任何可用的构建系统。 您可以 npm 安装它,jspm 安装它,如果您愿意,甚至可以使用 <script> 标签。

新项目中使用single-spa

1、创建相当简单 create-single-spa cli

https://github.com/single-spa/create-single-spa/

# 全局安装
npm install --global create-single-spa
# or
yarn global add create-single-spa
# 之后执行
create-single-spa


# 本地安装
npm init single-spa
# or
npx create-single-spa
# or 
yarn create single-spa


推荐设置

我们建议使用浏览器内ES模块 + import maps (或者SystemJS填充这些,如果你需要更好的浏览器支持)的设置。这种设置有几个优点:

    1. 公共模块易于管理,并且只下载一次。如果使用SystemJS,也可以预加载它们来提高速度。
    1. 共享代码/函数/变量就像导入/导出一样简单,就像在一个整体中设置一样。
    1. 延迟加载应用程序很容易,这使您能够加速初始加载时间。
    1. 每个应用程序(又名微服务,又名ES模块)都可以独立开发和部署。团队可以按照自己的进度工作、实验(在组织定义的合理范围内)、QA和部署。这通常也意味着发布周期可以缩短到几天,而不是几周或几个月。
    1. 很棒的开发人员体验(DX):转到dev环境并添加一个导入映射,该映射将应用程序的url指向您的本地主机。请参阅下面的章节了解详细信息。

single-spa中微前端的类型

    1. single-spa applications:为一组特定路由渲染组件的微前端。
    1. single-spa parcels: 不受路由控制,渲染组件的微前端。
    1. utility modules: 非渲染组件,用于暴露共享javascript逻辑的微前端。
容器Root 应用程序 沙箱 公共模块
主路由 有多个路由 无路由 无路由
API 声明API 必要的API 没有single-spa API
渲染UI 渲染UI 渲染UI 不直接渲染UI
生命周期 single-spa管理生命周期 自定义管理生命周期 没有生命周期
什么时候用 核心构建模块 仅在多个框架中需要 共享通用逻辑时使用

应用程序

single-spa 提供 registerApplication API注册应用

沙箱

主要是让您在多个框架中编写应用程序时可以在应用程序之间重用UI。
管理parcels的生命周期
mountParcelmountRootParcel 将立即挂载parcel并返回这个parcel对象。 需要卸载需要手动调用 parcel的 unmount.

Parcels 最适合在框架之间共享UI部分 ???
如: application1 用Vue编写,包含创建用户的所有UI和逻辑。 application2是用React编写的,需要创建一个用户。 使用single-spa parcels可以让您包装application2Vue组件。尽管框架不同,但它可以在`application2'内部运行。 将Parcels视为Web组件的single-spa特定实现。

公共模块

共享通用逻辑,可以是一个普通的js对象
如: 登录授权、 读取数据fetch
1、每个应用都访问服务器,这会在每个应用中创建重复的工作;
2、使用公共模块,创建一个实现授权逻辑的模块,通过导出/导入的方式使用这些授权

Root Config

根目录下的两个配置,用于启动single-spa应用

  • 所有微前端应用共享的根Html页面 【index.ejs】
  • 调用 singleSpa.registerApplication()的js 【study-root-config.js】
// single-spa-config.js
import { registerApplication, start } from 'single-spa';

// param1: 一个应用的标识
// param2: Function 一个应用要执行的代码
// param3: Function 何时激活这些应用:主路由
// param4: 可选的扩展参数
registerApplication(
  'app2', 
  () => import('src/app2/main.js'), 
  (location) => location.pathname.startsWith('/app2'), 
  { some: 'value' } 
);
registerApplication({
  name: 'app1',
  app: () => import('src/app1/main.js'),
  activeWhen: '/app1',
  customProps: {
    some: 'value',
  }
);
start();

参数说明

  • name:
    应用的标识,必须Sting

  • Loading Function or Application
    registerApplication 可以是一个Promise类型的 加载函数,也可以是一个已经被解析的应用。

    const application = {
      bootstrap: () => Promise.resolve(), //bootstrap function
      mount: () => Promise.resolve(), //mount function
      unmount: () => Promise.resolve(), //unmount function
    }
    registerApplication('applicationName', application, activityFunction)
    
  • 加载函数
    registerApplication的第二个参数必须是返回promise的函数(或"async function"方法)。这个函数没有入参,会在应用第一次被下载时调用。返回的Promise resolve之后的结果必须是一个可以被解析的应用。常见的实现方法是使用import加载:() => import('/path/to/application.js')

  • 激活函数
    第3个参数要求是一个纯函数(只依赖参数,不产生副作用), 根据 location.path决定哪个应用被激活。
    single-spa根据顶级路由查找应用,每个应用自己处理自身的子路由。
    支持通配符方式配置:'/users/:userId/profile'
    支持多路径方式配置:['/pathname/#/hash', '/app1']

    包含以下情况

    1、hashchange or popstate事件触发时
    2、pushState or replaceState被调用时
    3、在single-spa上手动调用[triggerAppChange] 方法
    4、checkActivityFunctions方法被调用时

  • 自定义属性
    第4个参数:参数会传给single-spa的 lifecycle函数

    singleSpa.registerApplication({
      name: 'myApp',
      app: () => import('src/myApp/main.js'),
      activeWhen: ['/myApp', (location) => location.pathname.startsWith('/some/other/path')],
      customProps: {
        some: 'value',
      },
    });
    singleSpa.registerApplication({
      name: 'myApp',
      app: () => import('src/myApp/main.js'),
      activeWhen: ['/myApp', (location) => location.pathname.startsWith('/some/other/path')],
      // 函数时,参数1:应用名:myapp, 参数2: window.location
      customProps: (name, location) => ({
        some: 'value',
      }),
      
    });
    
  • 最后调用 singleSpa.start()
    start() 方法,必须被single-spa的配置文件调用, 这样应用才会真的被挂载。 在start被调用之前,应用先被下载,但不会初始化/挂载/卸载。

    import { start } from 'single-spa';
    /*在注册应用之前调用start意味着single-spa可以立即安装应用,无需等待单页应用的任何初始设置。*/
    start();
    // 注册应用。。。。
    
  • 同时注册两个路由
    一个path的变动,同时两个应用被激活?? 可以。

<div>需要一个id,这个id的以single-spa-application前缀开头,后面接着你的应用的名字。比如,如果你的应用名字叫做app-name,就创建一个id为 single-spa-application:app-name的div。


<div id="single-spa-application:app-name"></div>
<div id="single-spa-application:other-app"></div>

构建应用

single-spa 应用与普通的单页面是一样的,只不过它没有HTML页面。在一个single-spa中,有N多被注册的应用,这些应用可以框架不同,自己维护自己的路由,只需要挂载便可以渲染自己的页面及功能。
“挂载”(mounted)的概念指的是被注册的应用内容是否已展示在DOM上。我们可通过应用的activity function来判断其是否已被挂载。未挂载前,一直休眠。

创建并注册应用
要添加一个应用,首先需要注册该应用。一旦应用被注册后,必须在其入口文件(entry point)实现下面提到的各个生命周期函数。

生命周期

  • 下载(loaded): 注册的应用在第1次 activity时开始下载,下载过程中尽可能执行少的操作,如果需要下载时执行的操作,可以放到子应用入口文件中。
  • 初始化(bootstrap/initialized):required 第1次被挂载前执行一次
  • 被挂载(mounted) required 应用被激活时执行,会根据当前url激活主路由,创建dom,监听事件,render等,子路由的改变(如:hashchange 或 popstate)不会再触发,需要应用自己处理
  • 卸载(unmounted) required 应用由激活变为未激活时触发,会清理挂载应用的dom,event,内存,全局变量,消息订阅等
  • 被移除(unloaded) 可选 无代表应用无需被移除,移除的应用,下次激活时,会重新初始化。可以实现 热下载。

注:
1、bootstrap, mount, and unmount的实现是必须的,unload则是可选的
2、生命周期函数必须有返回值,可以是Promise或者async函数
3、如果导出的是函数数组而不是单个函数,这些函数会被依次调用,对于promise函数,会等到resolve之后再调用下一个函数
4、如果 single-spa 未启动,各个应用会被下载,但不会被初始化、挂载或卸载。

超时配置
millis: 最终控制台输出的警告毫秒数
warningMillis: 警告每隔多少毫秒输出一次

切换应用时的过渡
在生命周期函数中自己实现过滤效果
demo:
https://github.com/frehner/singlespa-transitions
https://github.com/reactjs/react-transition-group

旧项目迁移至single-spa

拆分应用

前端系统应用

  • 1、一个代码仓库, 一个build包
    优点:容易部署,有单一版本控制的优点(monorepo)
    不足:项目越大时,打包越慢;构建部署在捆绑在一起,不能临时发版
  • 2、NPM包
    优点:开发熟悉,易实现;发布到npm前可以分别打包
    不足:父应用必须重装子应用重新构建部署
  • 2、动态加载模块
    优点:灵活,代码独立
    不足:搭建难度稍大
    实现:
    1. web服务器,创建动态脚本加载子应用正确版本;
    2. 使用模块加载,如: systemJs在浏览器动态下载并执行js

迁移现在应用

三步
1、创建一个single-spa配置
2、将spa应用转为注册应用
3、调整html,使用single-spa配置生效

1、实现生命周期
single-spa 生态系统 包含了single-spa对大部分框架的支持
https://single-spa.js.org/docs/ecosystem/
自己实现,就需要在 unmount 中,能够清理其 DOM 节点,DOM 事件侦听(所有的事件侦听,尤其是 hashchange 和 popstate)以及释放内存。

2、解决css、font、script依赖问题
现有spa应用转为无html应用后,这些资源依赖问题都需要解决:一种方案全部打包到js中; 其他方案呢?

沙箱 Parcels

single-spa的一个高级特性,与框架无关,api与注册应用一致,不同的是:parcel组件需要手动挂载,而不是通过 activity 方法被动激活。在不熟悉它之前,尽量不要用。

示例

// parcel 的实现
const parcelConfig = {
  bootstrap() {
    // 初始化
    return Promise.resolve()
  },
  mount() {
    // 使用某个框架来创建和初始化dom
    return Promise.resolve()
  },
  unmount() {
    // 使用某个框架卸载dom,做其他的清理工作
    return Promise.resolve()
  }
}
// 如何挂载parcel
const domElement = document.getElementById('place-in-dom-to-mount-parcel')
const parcelProps = {domElement, customProp1: 'foo'}
const parcel = singleSpa.mountRootParcel(parcelConfig, parcelProps)
// parcel 被挂载,在mountPromise中结束挂载
parcel.mountPromise.then(() => {
  console.log('finished mounting parcel!')
  // 如果我们想重新渲染parcel,可以调用update生命周期方法,其返回值是一个 promise
  parcelProps.customProp1 = 'bar'
  return parcel.update(parcelProps)
})
.then(() => {
  // 在此处调用unmount生命周期方法来卸载parcel. 返回promise
  return parcel.unmount()
})

Pacel配置
一个parcel只是一个由3到4个方法组成的对象。每个方法返回的都是一个prmise。 生命周期与应用基本一致。

  • 初始化(Bootstrap) 在parcel第一次挂载前调用一次
  • 挂载(mount) 在mountParcel方法被调用且parcel未挂载时触发,一般会创建DOM元素、初始化事件监听等,从而为用户提供展示内容。
  • 卸载(unmount) parcel已经被挂载,且满足下列某个条件:1、unmount()被调用; 2、父parcel或者应用被卸载
  • 更新(Update) 可选 调用parcel.update()时触发,使用者调用前需确认parcel已实现

single-spa的API

参考文档: https://zh-hans.single-spa.js.org/docs/api

single-spa的扩展

一般来说,微前端需要解决的问题分为两大类:

1、应用的加载与切换
2、应用的隔离与通信

应用的加载与切换需要解决的问题包括:路由问题、应用入口、应用加载;应用的隔离与通信需要解决的问题包括:js隔离、css样式隔离、应用间通信。

single-spa很好地解决了路由和应用入口两个问题,但并没有解决应用加载问题,而是将该问题暴露出来由使用者实现(一般可以用system.js或原生script标签来实现);qiankun在此基础上封装了一个应用加载方案(即import-html-entry),并给出了js隔离、css样式隔离和应用间通信三个问题的解决方案,同时提供了预加载功能。

single-spa原理

应用入口
single-spa采用的是协议入口,即只要实现了single-spa的入口协议规范,它就是可加载的应用。single-spa的规范要求应用入口必须暴露出以下三个生命周期钩子函数,且必须返回Promise,以保证single-spa可以注册回调函数:

应用加载

<script type="systemjs-importmap">
  {
    "imports": {
      "app1": "http://localhost:8080/app1.js",
      "app2": "http://localhost:8081/app2.js",
      "single-spa": "https://cdnjs.cloudflare.com/ajax/libs/single-spa/4.3.7/system/single-spa.min.js"
    }
  }
</script>
... // system.js的相关依赖文件

<script>
(function(){
  // 加载single-spa
  System.import('single-spa').then((res)=>{
    var singleSpa = res;
    // 注册子应用
    singleSpa.registerApplication('app1',
      () => System.import('app1'),
      location => location.hash.startsWith(`#/app1`);
    );
    singleSpa.registerApplication('app2',
      () => System.import('app2'),
      location => location.hash.startsWith(`#/app2`);
    );
    // 启动single-spa
    singleSpa.start();
  })
})()


</script>

// single-spa 的start方法
export function start(opts) {
  started = true;
  if (opts && opts.urlRerouteOnly) {
    setUrlRerouteOnly(opts.urlRerouteOnly);
  }
  if (isInBrowser) {
    reroute();
  }
}

single-spa的弊端:
首先我们必须手动实现应用加载逻辑,挨个罗列子应用需要加载的资源,这在大型项目里是十分困难的(特别是使用了文件名hash时);另外它只能以js文件为入口,无法直接以html为入口,这使得嵌入子应用变得很困难,也正因此,single-spa不能直接加载jQuery应用。
single-spa只是负责把应用加载到一个页面中,至于应用能否协同工作,是很难保证的

qiankun解决方案

https://github.com/umijs/qiankun

1、应用加载
使用npm插件 import-html-entry

主要方法:importHTML(url, opts = {})
简单点说:importHtml 通过fetch获取远程的脚本、样式文件内容, 然后通过正则表达式,把js,css提取出来,放到各自的数组里,js能过eval执行,并导出供其他模块调用

2、css,js隔离

  • 通过importHtml 加载html并把外部样式转为内部样式(使用类个shandow dom 或 vue scope)方式, 实现样式隔离
  • execScripts方法: 为应用生成一个window的代理对象,作为参数传入,以保证不影响全局window; 在ie11通过快照方式实现隔离
//正常实现js隔离
(function(window, arguments){
  // do something
})(window)

// execScripts
(function(window, arguments){
  // do something
})(window.proxy)

4、应用通信

// 基座中
import { initGlobalState, MicroAppStateActions } from 'qiankun';

const initialState = {};
const actions: MicroAppStateActions = initGlobalState(initialState);

export default actions;


// 子应用中监听
actions.onGlobalStateChange (globalState, oldGlobalState) {
  ...
}

// 子应用中修改
actions.setGlobalState(...);


webpack5 模块联邦 VS single-spa

模块联邦: webpack 受打包工具 和 生态的限制,
single-spa: 已经有一些成熟的解决方案:qiankun & 京东的MicroApp

京东出品微前端框架MicroApp介绍与落地实践
https://mp.weixin.qq.com/s/6A6TqQpWgN1_KoxUMx3FFw
QA:

如何在应用程序间共享状态

1、建议尽量避免应用共享状态,如果出现,可以优先考虑重新划分应用的边界
2、实现方案:

  1. 创建可以缓存请求及其响应的共享API请求库。如果同一个API被多个应用重复命中,则使用缓存数据。
  2. 将共享状态公开为导出,其他的库可以导入它。可观测值(如:RxJS) 在这里很有用,因为他们能够将新值流式传输给订阅服务器。
  3. 使用custom browser events来交流。
  4. 使用cookies, local/session storage或其他能够存取状态的工具。

参考文档:
https://single-spa.js.org/
https://single-spa.js.org/docs/examples/

https://github.com/systemjs/systemjs
SystemJS >=3 已实现IE11的polyfill 目前已到 6.10.1

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容