适用于既有大型MPA项目的“微前端”方案

一、背景

对于大多数有点历史的复杂前端项目来说,应该已经经历了从刀耕火种的大型单仓库构建到多业务应用独立开发部署的过程。当用户访问页面时,由 nigix 等负责根据路由分发到不同的业务应用,由各个业务应用完成资源的组装后返回给浏览器。这种情况下,开发、构建已经可以各自独立进行,在这样一套健全体系下的开发者们,想必是很幸福的。

以有赞微商城后台为例,针对B端业务,我们就已经划分了数十个的应用,可独立进行开发、打包和部署。如下图所示:

但在业务日趋复杂,页面依赖资源越来越多的情况下,翻开 页面加载优化 的万能工具箱,用尽各种招数,都很难达到接近单页的效果。毕竟, MPA 架构的前端不是 生而为快 ,其最大的优势在于开发和维护的高效。

那么,在面对一个大型的 MPA 架构前,我们的页面还可以再快一点吗?对于有赞的前端体系来讲,在进行业务域的拆分应用后,业务级别的独立开发、部署已经变成了日常。在单个业务域内,其实也存在SPA的模式,但大都仅限于一个功能点下的列表——详情页的跳转。要完成业务域内的全单页,需要完成的工作量和踩的坑已不敢想象,更别说仅实现了业务域内单页,带来的实际体验提升并不大。那我们还有别的办法吗?

这时候天空飘来了两个字—— Micro Frontend ,微前端。微前端的定义想必大家都看了很多,大多数是起源自于 micro-frontends.org 和各个大牛自己的独到见解。本文所介绍的方案并非全套的微前端方案,不包含独立发布、部署、依赖拆分这一部分的内容。这次分享的目标是以有赞微商城后台的改造为例,提供一些可参考的经验,如何在一个已经完成独立发布、部署的 MPA 体系下,实现微前端中的子页面分发和组合的部分,实现接近单页的效果。

二、概述

目前业界已经产出了一些优秀的微前端方案,比如热门的基于 single-spaqiankun 。在分析了这些微前端方案的实现,并结合团队内的现状后,我们实现了自己的渐进式微前端方案—— ZanSpa (命名就是这么简单朴素)。主要设计思路如下图所示:

其中核心模块为 RouteMonitorPageLoader 两部分,分别负责路由导航和子页面资源的解析组装。好了,有了整体的印象,接下来会依次介绍各个主要模块和流程的实现。

三、说细点

子页面组合方式

微前端的子页面组合方式:包含构建时组合和运行时组合,既然是低成本接入,基于已有的业务独立打包的形式,同时能做到真正的技术栈无关跟独立部署,运行时组合自然成了我们的首选。

子页面拆分

开始前,我们对现有的页面加载流程做一些简单分析。

我们在浏览器输入youzan.com,请求经过无线网有线网,A机房B机房,终于到了我们稳定的有赞服务器,接入层根据请求路由特征,转发到对应业务应用的机器,业务应用的 node 服务再组装返回 html 。其中包含了微商城后台前端各业务都会依赖的公共资源,包括脚本和样式,和业务页面自身依赖的资源。

对于上图中所示的静态资源,这里我们以业务A(对应路由/routeA)和业务B(对应路由/routeB)为例,可以分为三类:

  1. 跨业务共用资源(shared-css、shared-js),routeA和routeB下页面都会请求
  2. 业务应用内基础资源(base-css、base-js),routeA路由下子页面都会请求
  3. 页面级资源(page-css、page-js),routeA路由下的页面C才需要,同是routeA下的页面D可能就不需要

在页面切换中,对于在微商城后台内所有的业务,跨业务的共用资源其实只需要被加载一次,而业务内的基础资源,在业务域的页面间跳转时,比如从 /routeA/list/routeA/detail 也只需要加载一次。这样,最优的情况下,我们只需要加载页面本身需要的 page-csspage-js ,从而极大的提高页面切换加载的速度。

于是我们对 html 模板的生成逻辑进行拆分,服务器在面对同样一个路由时,根据固定参数 zanPageMode 决定是需要子页面形式的页面还是完整的页面(可在基座外独立打开)。如果是子页面资源的请求,则使用精简后的模板,其中去除了跨业务共用资源引用,这些资源只需首屏加载即可。对于业务内的基础资源,在页面切换时,对子页面依赖的资源进行diff,如果是已加载的样式或脚本资源,则保留,仅对页面级的资源进行替换,如 pageA.csspageA.js 更新为 pageB.csspageB,js

子页面资源格式

config-entry

我们起初也尝试了使用 config-entry (json格式的子页面配置信息)进行子页面信息的传输,形式如下: 其中 bodyClass 的作用是我们存在部分子页面需要指定全局样式主题。

{
  "title": "有赞微商城",
  "template": "
", "bodyClass": "blue-thme", "scripts": "yzcdn.com/routeA/base.js, yzcdn.com/routeA/pageA.js", "styles": "yzcdn.com/routeA/base.css, yzcdn.com/routeA/pageA.css", }

在使用 config-entry 时遇到了几个很难优雅处理的问题:

1、模板标签的双向转义

服务端在返回子页面信息的json时,由于 templatehtml 格式,其中可能存在双引号、换行符等特殊字符,需要先将 template 内的换行符进行替换,将双引号进行转义,基座应用在获取到子页面数据后需要再对相应的特殊字符进行反转义和替换。

2、内联脚本

我们子页面依赖的 scripts 资源中还存在内联脚本的情况,同样存在与模板相似的问题。且内联脚本中的 js 代码各种字符都可能存在,一味的转义处理不当可能就会造成数据或执行错误。

3、复用性

考虑到我们业务的页面还会被其他二方的平台引用,如果将页面模板输出拆分为目前基于 Nunjuckshtmljson 两套,由于格式的不同,很难做到其中一些模板片段和逻辑的复用,对于其中一些资源位置或形式的改动,可能两种格式需要分开处理。

html-entry

在使用 json 格式踩坑无数后,我们最终采用了 qiankun 类似的方案, html-entry 。使用html格式进行子页面资源的组织,可读性和维护性更高,更接近最后页面挂载后的效果,也不存在需要双向转义的问题。且与现有 nunjucks 模板无缝衔接,只需要做一些很小的改动,就可以将原有的页面模板,经过冗余资源的拆分后,输出为子页面的 html-entry

DOMParser

本着不重复造轮子(拿来主义)的原则,对 html-entry 的解析开始也使用了 qiankun 内部使用的 import-html-entry 模块。但由于我们的部分页面为了提高首屏打开速度,会将一些依赖的全局数据塞到一个内联脚本中作为 window 变量进行初始化,而 import-html-entry 内部使用了正则表达式进行 stylelinkscript 标签的提取,在内联脚本中数据量较大(100k左右)时正则提取存在明显的性能问题,导致页面加载过程肉眼可见的延长。于是我们转而找到了另外的替代方案—— DOMParser

DOMParser 类似的还有 div.innerHtml 或使用 Range.createContextualFragment 。但在实际使用中,虽然 DOMParser 相对于使用 div.innerHtml 传入需要解析的模板和 Range.createContextualFragment 性能会较差一些,不过在也就是几毫秒到十几毫秒的区别。而且 DOMParser 强大的解析能力,可以充分解析 html-entry 中标签及其属性,最后获取到的就是一个 document 对象,使用我们熟悉的 DOM api 即可访问或修改相关数据。

!!! 前方踩坑警告

但DOMParser也不是完美的,在解析自闭合的 div 标签时(如

),会导致结构错乱,原因可能是 DOMParser 在解析div时默认其是存在结束标签的。解决方案是在获取到 html-entry 后,先进行一次全局的替换,补充结束标签。使用如下的正则简单处理即可,基本不会影响解析性能。

html = html.replace(/]*)\/>/, '');

基座改造 RouteMonitor & PageLoader

整个单页容器的部分我们封装成了 ZanSpa 模块,对外仅提供 init(options) 的方法,支持一系列的自定义行为和生命周期钩子。

ZanSpa 启动时,会实例化内部的两个核心模块 RouteMonitorPageLoaderRouteMonitor 负责路由切换的监听,决定什么时候应该切换子页面。 PageLoader 负责在路由切换时,加载并解析相应的子页面,并处理子页面间的副作用和生命周期的更替。

RouteMonitor

该模块的作用是拦截可能修改当前路由的事件及行为,并判断路由的改变是否需要出发子页面的更新。

1、监听全局点击事件,判断如果要走子页面的更新逻辑,则拦截后调用 PageLoader 进行更新。这里需要注意的是如果同时按住 cmdctrl 或者 shift 键的点击会打开新 tab ,需要保持原有行为。

每次点击后会通过 validateUrlChange 方法判断路由的变更是否需要按照子页面形式进行切换。该方法会解析判断新老的 url( sourceUrldestUrl ),判断两者是否相同(除 hash 外)。

  1. pathname是否相同
  2. 是否都存在hash
  3. sourceUrlhashdistUrl 没有 hash

!!! 前方踩坑警告

第 3 点需要特别注意,对于 hash 的变更,理论上我们是不应该干预的,避免影响 react-route 之类的基于 hash 实现的单页和浏览器的默认 hash 跳转行为。但对于 pathname 相同的 url 间跳转时,如果 sourceUrlhash ,而 destUrl 没有 hash 的情况,是需要进行劫持的,否则浏览器的默认行为就是页面的重载,

2、拦截原生 history 变更

  • 监听全局 popstate 事件,并在 state 统一返回页面url,方便浏览器前进后退时通过 url 获取相应的子页面。
const globalHistory = window.history;

window.addEventListener('popstate', () => {  
  const url = window.location.href;
  globalHistory.replaceState({ url }, '', url);
});
  • 劫持 historypushStatereplaceState 方法,然后从 state 中获取 url ,调用 PageLoader.loadPagesOfUrl 进行子页面的更新。

3、提供统一的跳转方法 ZanSpa.navigateTo(urlOrEvent) 。由于 window.location 为native对象,无法被劫持,所以子页面通过 window.location.href = '/routeB/pageC' 进行跳转的地方需要使用该方法进行替换。 ZanSpa.navigateTo(urlOrEvent) 的实现也很简单,基于 window.history.pushState API,支持 MouseEvent 类型的参数,可以直接作为 a 标签的点击事件的回调。

PageLoader

PageLoader主要负责子页面资源的获取、解析和生命周期管理,对外提供 ZanSpa.registerPage() 进行页面注册。子页面通过该API声明子页面的路由匹配规则,挂载和卸载逻辑。

ZanSpa.registerPage() 的参数定义如下:

export interface IMicroPageProps {  
  /**
   * 页面注册name,为避免重复,**推荐按照biz-feature-page的形式命名
   */
  name: string;
  /**
   * 没有特别逻辑可以不传。声明路由匹配规则,可以使用字符串、正则表达式或函数;
   */
  activeRoute: string | RegExp | ((url: string) => boolean);
  /**
   * 页面初始化生命周期回调
   */
  bootstrap?: LifecycleCallback;
  /**
   * 页面挂载时的生命周期回调。如果使用的是react,这里可以使用ReactDOM.render进行根节点渲染。
   */
  mount: LifecycleCallback;
  /**
   * 页面卸载时的生命周期回调。如果使用的是react,这里可以使用unmountComponentAtNode进行react组件的清理。
   */
  unmount: LifecycleCallback;
  /**
   * 自定义参数
   */
  customProps?: any;
}

下面是 PageLoader 的页面更新流程:

1、请求子页面资源

RouteMonitor 监听到跳转行为或外部通过 ZanSpa.navigateTo 进行跳转后,如果 RouteMonitor 认为此次路由变更需要页面更新,则会调用 PageLoaderloadPageOfUrl 进行加载,并显示页面加载的 Loading

我们这里没有引入中心化的 路由-子页面 配置管理,因为现有的统一接入层已处理了类似的逻辑,对于到来的请求,根据其路由特征转发到对应的 node 服务,由 node 服务再根据内部路由规则返回相应的资源。所以 PageLoader 在处理新的路由请求时,需要通过 loadPageOfUrl 拼接特殊参数后将请求发出,node 端收到页面请求包含该参数时即返回子页面模板实例化后的 html-entry

2、子页面资源解析&diff更新

在成功获取 html-entry 后, PageLoader 会通过上述的 DOMParser 将其解析为一个 document 对象(与全局document对象类似),内部再进一步解析出其 entry 中包含的样式、脚本、模板资源,分别由相应的方法进行 diff 更新。

  • 样式和脚本:具体的 diff 规则也很简单,对于 link 标签就判断 href 属性,对于 script 标签就判断 src 属性,内联的样式和脚本不做 diff 。然后按照约定样式插入到挂载节点的顶部,脚本则插入到挂载节点的尾部。
  • 模板:模板则根据 ZanSpa 初始化时传入的容器节点 ID,清空容器节点后填充进新的模板。

3、子页面注册

在上一步中,资源解析并且 diff 更新后,样式、脚本和模板加载完成。随着业务 chunks 脚本执行,此时就会触发业务页面入口处调用的 ZanSpa.registerPage(pageInfo) ,子页面的自动注册完成。所以我们子页面的配置收集是动态完成的,不需要集中式统一维护子页面配置,只需由子页面各自进行维护, html-entry 加载完成同时也加载了子页面配置信息。但同时因为加载前不知道子页面的具体信息,目前还无法做到指定子页面的预加载。

为了减少业务接入成本时,调用 registerPage 时的 activeRoute 参数默认会使用页面加载时的 pathname 。该参数支持 string 、正则表达式和函数三种类型。如果子页面中存在冲突的,可以自定义 activeRoute 来解决。注册完成后,将子页面信息存入 microPages 数组,以方便之后的生命周期更新。

4、生命周期管理

子页面资源加载并且更新完成后,同时新的子页面通过 registerPage 已完成注册。此时就需要根据各个子页面的 activeRoute 进行生命周期的更新了。这个过程核心代码如下,这里所有的资源加载和生命周期会被包装成 Promise ,以便于异步的处理,也可根据需要直接使用 async/await

this._unmountPages()  
  .then(() => {
    // bootstrap
    return this._bootstrapPage(pageResouce);
  })
  .then(() => {
    // mount
    return this._mountPages();
  });
})
  • unmountPages :该方法会遍历所有目前已注册的子页面,判断其是否应该被卸载,然后调用其声明的 unmount 方法进行卸载。

  • mountPages :旧的不去,新的不来。当旧页面被卸载后,此时按照类似的逻辑,找出需要被挂载的子页面,并行的调用其 mount 生命周期回调。挂载的过程一旦出错,会暂时将该子页面状态设置为 PageStatus.Mounted ,然后尝试将其卸载。

  • 副作用处理:页面在通过 registerPage 注册时,会对其生命周期进行包裹,以便于在其 mount 时启动全局事件和定时器的收集,并在其卸载时清理收集到的全局事件监听器和定时器。这个方案并不是完整的沙箱,目前不支持window变量的收集与恢复。

  • 开发调试信息: RouteMonitorPageLoader 中记录了页面各阶段加载耗时,资源复用情况,全局事件和定时器清理统计及内存占用情况(开发环境)。考虑到单页化改造后,难免有一定的内存泄漏,再内存占比达到一定阈值时,在页面跳转时强制进行整页刷新。该特性通过 performance.memory API 实现,浏览器兼容性较差,仅作辅助使用。

其他坑

全局组件清理

对于不在容器节点内的全局组件如 NotifyDialog ,子页面 unmount 时也需要自动清理。即使在确定这些组件是React组件和挂载节点的情况下,由于基座和子页面React实例隔离,基座内的 unmountComponentAtNode 并不能彻底清理这些组件实例。我们的解决办法是,业务应用在 registerPage 时,在 customProps 中的 unmountComponent 回传业务方卸载方法,例如 React 就是 unmountComponentAtNode ,然后在 ZanSpabeforeUnmount 钩子中处理需要清理的组件,这个可以视具体技术栈和组件库而定。

子页面间通信

我们子页面间通信的场景较少,目前采用的是自定义事件,并以 zan-spa: 作为统一的事件前缀。

pushState跨域问题

需要注意业务内有没有跨域的链接存在,如果跳转时是一个跨域的 url , pushState 的调用会出现安全错误, SecurityError: Failed to execute 'pushState' on 'History' ,导致整个流程停止,用户点击后无反应。我们的 ZanSpa 提供了 beforeLoad 的钩子,其中可以处理不允许走单页加载的情况。 RouteMonitor 在跳转前会调用该钩子,如果其返回false,则通过 window.location.href 打开该链接不走单页模式。

MPA模式下,开发者其实无需考虑很多副作用,如全局事件监听器和轮询的定时器,都会随着页面刷新烟消云散。但进入到微前端的 SPA 时代后,虽然基座本身也会处理子页面的副作用,但仍需要业务页面的开发者随时保持 我注册,我清理 的意识,不留隐患。

灰度控制规则

由于上线后影响面较大(每个页面),也要支持各个业务应用的分开接入,所以在灰度和开关控制上我们也考虑了很多,以支持一旦发现线上有意料外的 “feature”,可以精确的控制某个店铺或者页面是否开启。由于逻辑也比较复杂,这里就不展开了,感兴趣的可以私下沟通。

基座更新策略

由于基座承担着管理子页面的职责,并且在子页面更新时并不会更新,如果我们修改了基座的代码,怎么实现基座的更新呢?这里我们利用现有的打包流程,会将当前的基座资源版本信息在基座已有的配置信息接口中返回。该接口中还包含了导航菜单和权限的最新数据,这个接口会在每次子页面切换后更新(5秒的 debounce 处理),再下次子页面切换时,如果发现基座版本已落后,则强制走 MPA 模式加载。

快速连击的防御

单页化后,用户的每次跳转由浏览器处理变成了 ZanSpa 处理,而其中 PageLoader 对子页面的 bootstrap (资源diff后的更新)过程是不适宜被中断的,所以考虑到稳定性的问题,在用户点击跳转时,需要确认是否有页面正出于 bootstrap 状态,如果有则需要拦截并进行提示。

效果展示

页面切换速度提高明显,而且对于基座本身依赖的一些接口请求(仅限时效性要求不高的接口),在单页化后基本只需访问一次,大大地件减少了基座依赖接口的后端压力。

下面是改造前后的对比图,测试前已清除缓存。在页面静态资源已缓存的情况下,速度的差异较小,但相对于多页切换时的整页白屏,改造后的体验要好很多。

改造前:

改造后:

四、待完成的事项

如果要作为完整的微前端方案,还有不少的事情要做,这是接下来的一些计划,欢迎有兴趣的有赞同学来提想法和添砖加瓦~

config-entry

以下是个人微信,方便交流。

欢迎关注我们的公众号