如何设计微前端中的主子路由调度
即便在微前端的内核板块中,路由问题也没有占多大的篇幅……但是它牵涉到集成逻辑、沙箱等核心逻辑;另一方面,想完美地解决路由问题也并不容易,内里存在大量的细节可供挖掘。所以,展开讨论下此问题并不会无趣;而作为一个特别的视角,我们也可以藉由它对整个微前端体系管窥一二。
领域问题背景
对于点进本文的读者而言,相信大家已经很熟悉微前端需要解决的问题场景:
巨石应用的分治开发。
新老项目线上并存。
三方系统集成。
……
以上问题场景的具体案例这里不再展开。值得注意的是,根据场景的需求不同,微前端的实施方案会有所细节差异,比如:
三方系统集成的场景下,方案必须要能兼容不同的技术栈;而对于阿里内部项目来说,一般只用考虑 React 跨版本的问题。
诸如邮箱这样的多 tab 应用需要能隔离各个 tab 下的子应用;而其他一些独占式界面的应用则不必担心这点,始终维护一个活跃的子应用实例即可。
……
—— 而本文要讨论的路由问题,在某些场景下可能也不是问题,比如主应用集成的是 widget 粒度的模块,此时 widget 内部并不关心路由,所以只需要处理主应用本身的路由逻辑即可。
但是,在更普遍的场景下,子应用本身也是一个 SPA,有自己的路由逻辑。于是,当主应用集成子应用时,对路由的处理会碰到一系列新的问题。如何解决这些问题,从而实现一个正确的主子路由逻辑?这就是后文将展开的内容。
期望的主子路由效果
那何谓「正确的主子路由逻辑」?
不管具体的实现手段如何,我们希望主应用在集成了子应用之后,最终达到如图的效果:
我们虚构了一个主应用叫做 cow-and-chicken,它会集成 chicken 和 cow 两个应用,通过 tab 来切换。默认情况下,主应用会指向 cow 应用,并且,遵循如下的路由效果:
通过一个具体的路径访问主应用,将能调度到子应用的对应路由。比如访问
cow-and-chicken.com/cow/detail
返回 cow 应用的 detail 页面。子应用内部的路由切换后,主应用的路由也同步变化。比如 cow 应用回到首页后,浏览器地址栏将显示
cow-and-chicken.com/cow/
。浏览器的前进、后退功能正常使用。
同时,我们希望集成过程不需要对子应用作改造,不用额外的约定,也不用担心子应用只能适配诸如独占式的特定主应用场景……总之,解决方案越普适越好。
本小节描述了主子路由调度问题在整个微前端技术体系中的位置,如果仅关心路由问题的具体解法,可以跳过这一节。
路由问题的解法不是孤立存在的,它会关联到微前端体系中的一些其他要素。理解这些要素之间的逻辑关系能够辅助我们认知整个微前端技术体系。
微前端体系全景
这是我所在的阿里云开放平台团队,围绕微前端做的一些建设。
注意到,其中左栏是平台(配置、管控)相关能力,上层是阿里云控制台侧的一些解决方案包装……即是说,大半的工作是贴着业务建设的。对于不同 BU 的团队来说,这些需要根据自己所在业务的特性来定制对应的方案。
而中间下层的「微前端内核」部分,则偏「纯技术」,几乎可以在任何(基于微前端的)业务场景下通用,这部分也更容易让没有阿里云业务概念的读者理解。
微前端内核相关部分展开
上图的高亮部分是和路由问题相关的技术点。
我们这里继续展开一下「微前端内核」部分,主要包括:
主应用框架侧的加载、渲染、通信、路由。
以及子(微)应用侧的 JS Bundle 运行环境、沙箱。
另外的「相关部分」则是:
为了能在子应用容器和主应用框架中正常运行,需要对子应用作改造 —— 这里的改造一般通过工程化工具自动完成。
而主应用初始化时需要的子应用信息,则通过配置管理平台来提供。
以及常规的脚手架等。
图中高亮的部分便是和路由有关的技术点,这些都会在后文提及。
我们先来一个前置的灵魂拷问:为什么一定要有前端路由,它解决什么问题?
事实上,router 技术并非一蹴而就,它是一步步发展成今天这样的 ——
Controller
在十多年前,经典的 MVC 应用架构在后端大行其道,人们(包括我)自然地想在前端中也实践这一点。
当时按照 MVC 构建后的各个角色形如上图。
其中,Model 和 View 这两层比较容易拆解,各自的代码表征也很明确,但是 Controller 部分怎么写都觉得不优雅 —— 必须在其中编写大坨的胶水代码,才能保证页面状态的正常运转。
比如对于前文提及的 cow-and-chicken 应用,Controller 需要做的事大概是:绑定 cow、chicken 按钮的点击,定义对应的逻辑,然后调用子 Controller 进一步处理。要写得更优雅的话还可以定义个状态机,用 EventEmitter
来触发事件流转。
代码形如:
// 维护当前的页面状态
var currentState = 'default';
// 类似于今天的路由表
var stateMap = {
'default': () => {},
'cow/home': () => {},
'chicken/home': () => {}
};
// controller 是个 EventEmitter
controller.on('statechange', (e) => {
stateMap[e.type](e.payload);
});
// 绑定页面的物理事件
document.on('click', (e) => {
switch(e.target.className) {
case 'cow-home-button':
currentState = 'cow/home';
break;
}
controller.emit('statechange', {
type: currentState,
payload: {}
});
});
Router
后来我认识了 Backbone,一个很经典的 MVC 框架。但是让我很惊讶的一点是,它没有 Controller,取而代之的是 Router 模块。
而上述的代码实现,正可以被 Router 优雅取代:
currentState
不用额外维护,因为它永远等于location.hash
;stateMap
变为路由表;事件不再需要手动绑定,使用天然的 anchor 即可。
这简直是一个天才的发明:享受到了 MVC 式清晰的应用架构,而且只需要配个路由表就能运行。
打那之后,我再也没见过有人手动实现一个 Controller,以至于到了后来的 MVVM 时代,Router 还是作为页面状态机的驱动器,享有一席之地。
History API
后来,History API 推出,作为标准的管理页面状态工具。相比于被妙用的 hash,官方出品的 History API 可以完全覆盖上述 Controller 的能力,并且功能更强大。
但是请注意,History API 严格说来并不完全等同于「前端路由」,其实它支持在不改变当前 URL 的情况下去改变页面状态。这个时候,页面的状态信息只维护在内存中。
// 不影响 URL 变化的 pushState
history.pushState('new state', '', undefined);
从这个角度来说,前端路由不是必选项,应用只要能正确地维持页面状态就行,不是非得把页面状态显式输出……只是实际开发中,我们几乎不会使用无 URL 变化的 pushState
。这是因为我们通常希望把页面状态暴露给用户,以便用户能看到(了解当前状态),能输入(影响当前状态)。
所以,粗略地来说,前端路由必不可少,对开发者和用户来说都很重要。如此,就让我们从简单的路由调度方式拓展到更复杂的情况,最后实现一个尽可能完美的方案。
主应用完全不处理路由
主应用不根据路由调度子应用,子应用内部的状态变化按照原样反映到主应用路由。
默认情况下,主应用就只作为容器,触发加载和渲染子应用。此时,主应用对子应用的加载定义,可以写死在一个配置 map 中,并且通过手动绑定外部事件(如点击 tab)触发。
而子应用自己的路由逻辑该干嘛还是干嘛,URL 的变化直接反馈到当前地址栏中。
总结一下主子的关系:单向传递参数(参数写死),后续子应用的状态变化按照原逻辑运行。
这时我们很容易发现以下问题:
不同的子应用如果存在相同的路由,就会冲突。
由于主应用没处理路由,带着路由进页面的时候不会产生效果(而是进入初始状态)。
子应用和主应用共享路由
主应用根据路由调度子应用,子应用内部的状态变化根据特定规则反映到主应用的路由。
对于上个方案中「路由冲突」这种典型的「共享资源抢占」问题,解决办法很简单 —— 对共享资源作切片,通过共同的前缀约定等方式来规避资源抢占的问题,隔离不同的子应用。
即,为各自的路由表(以及链接)增加特定的前缀(比如使用当前子应用的 name):
// 使用子应用名称来对共享资源切片
const APP_NAME = 'cow';
<Route path={`/${APP_NAME}/home`} component={Home} />
<Route path={`/${APP_NAME}/detail`} component={Detail} />
另一方面,由于路由内已经包含了子应用信息,所以主应用可以在进入页面后直接路由到对应的子应用。这需要在主应用侧增加路由表,同时,它原来写死的用来下发的参数可以转而写到路由配置中。
图中点击后退的效果和点击 /cow/home
大抵相同,便不列出。总之,(竟然)已经能满足期望达到的终极效果了。
总结一下主子的关系:单向传递参数,参数从 URL 中来,主应用控制大粒度的路由,后续子应用的状态变化稍加改造运行。
虽然呈现效果没问题,但是这时需要将约定侵入子应用的逻辑,很不优雅。
注脚:JavaScript 沙箱
提到「侵入」,自然会想到「隔离」。接下来我们必须引入沙箱机制,这里简要介绍下 JS 方面的隔离。
隔离核心需要解决的问题一句话就能讲清楚:不同子应用对全局资源的访问需要控制。
而在浏览器的语境下,这意味着两点:
需要隔离对全局上下文产生的副作用。
特别地,要隔离 DOM / BOM 对象。
隔离全局变量的核心解决办法是将全局变量降为局部变量,常规的做法就是创建一个闭包包住子应用,子应用的全局变量变成闭包的 arguments
。
// Node.js 中的「The Module Wrapper」
(function(exports, require, module, __filename, __dirname) {
// 实际的 Module 代码
});
在创建沙箱的时候,传入全局变量的副本即可(比如 g_config
)。
如果真的需要对全局产生副作用,那就要设置白名单,传入引用。
而对于浏览器 API 这样的全局变量,不可能完全模拟一个副本,怎么办?
这时我们想到,浏览器中 iframe 可以提供完全隔离的 window
、document
等上下文,毕竟生成一个 iframe 就是创建了一棵新的 DOM Tree。因此,我们可以创建一个同域下的 iframe,让其正常返回一个 200 响应的空 HTML,然后把它们作为闭包的入参传递给子应用即可。
// 子应用被包上 wrapper
__CONSOLE_OS_GLOBAL_HOOK(id, function(exports, require, module, {
window
}) {
// 实际的子应用代码
});
// wrapper 的实现
const frame = document.createElement('iframe');
const _window = frame.contentWindow;
function __CONSOLE_OS_GLOBAL_HOOK(id, entry) {
entry(exports, require, module, {
window: _window
});
}
拿到隔离后的 DOM / BOM 对象之后,我们并不直接加以使用,而是对某些对象加以 Proxy,从而去插入一些自己需要的逻辑,或者将部分逻辑委托给主应用,或者干脆禁用某些方法。
class History {
constructor(_history) {
return new Proxy(_history, {
get(target, name) {
switch(name) {
case 'pushState':
// 在这里魔改掉
break;
}
}
});
}
}
关于我们沙箱的更多解读,可移步《阿里云开放平台微前端方案的沙箱实现》。
子应用维护隔离的路由
主应用根据路由调度子应用,子应用内部的状态变化反映到各自的沙箱中,互不干扰,但不会反映到主应用的路由中。
在使用了沙箱后,子应用操作的 location
/ history
/ document
都变成了 iframe 的。此时,大家不需要再争夺主应用的资源,不需要提前约定,可以相安无事地执行各自的路由逻辑;并且自然地,此时子应用和主应用的路由也隔离了起来。
点击子应用中的链接,被框架拦截默认的链接跳转行为,改为 pushState
,由于子应用使用的是沙箱的 history
,所以最终实际上是改变了 iframe 的 URL。(如果使用 hash 的方案也是一样,anchor 点击被拦截,改为 pushState
。)
那此时点击会不会触发主应用侧的 popstate
呢?答案是不会,因为子应用 DOM 中的点击事件最终会冒泡到沙箱的 document
,和主应用并无关联。
主应用和子应用的路由隔离后,又产生了新的问题:由于无法感知子应用路由的变化,主应用不能体现路由逻辑。
子应用路由同步回主应用
主应用根据路由调度子应用,子应用内部的状态变化反映到各自的沙箱中,互不干扰,并且最终会反映到主应用的路由中。
一旦谈到不同实体之间的信息同步,那么就少不了消息通信机制。思路自然而简单:
子应用额外监听路由的变化,产生新路由后将地址作为消息体发送给主应用。
主应用监听来自子应用的消息,获取新路由后去改变自身的路由。
这里有个细节的问题:子应用监听路由变化然后通知主应用,这岂不是又侵入了子应用代码?
还记得之前提到的沙箱吗?我们 Proxy 了 history
后正是为了做这些脏活!在沙箱层会 Proxy 监听 history
变化,postMessage
出去:
switch(name) {
case 'pushState':
return (...args) => {
const returnValue = _pushState.call(_history, ...args);
// 插入消息通信逻辑
frame.postMessage({
type: 'statechange',
data: {
location: frame.location,
state: _history.state
}
}, '*');
return returnValue;
};
}
主应用侧监听此消息,然后拼出路由后 replaceState
。
frame.contentWindow.addEventListener('message', (e) => {
const payload = e.data;
switch(payload.type) {
case 'statechange':
const { state, title, location } = payload.data;
const url = location.href;
window.history.replaceState(state, title, url);
break;
}
});
主应用接收到消息后改变自己的路由,这会引起「主应用 popstate
<-> 子应用 popstate
」的无限循环吗?答案是并不会。因为 pushState
/ replaceState
不会触发 popstate
事件,所以在「子应用路由同步回主应用」这个阶段,主应用仅仅是「展示」当前的路由状态,而不包含其他逻辑。
一切看起来都很好,最后一个问题,为什么主应用侧用 replaceState
而不是 pushState
?
注脚:Joint Session History
当一个页面中的 iframe 跳转后,parent 页面的地址栏、history
会如何变化?
现象是:一个 parent 页面可以通过前进后退按钮(或者 history.forward()
/ history.back()
)控制 iframe;反过来,iframe 本身的状态变化也会影响到 parent 页面的 history
。
这种现象是对一个标准的实现,叫做 Joint Session History。
The joint session history of a > History object is the union of all the > session histories of all > browsing contexts of all the > fully active > Document objects that share the > History object's > top-level browsing context, with all the entries that are > current entries in their respective > session histories removed except for the > current entry of the joint session history.
简单来说,所有 iframe 页面会共享 top 页面的 history
。
所以,当子应用路由变化,即意味着沙箱的 iframe 地址发生变化,主应用本身的 history.length
也会 +1。如果此时不做路由同步回主应用的操作,那么主应用 URL 没有变化;点击后退,主应用 URL 仍然不变,但是子应用会回到上一个状态。
行为 | 主应用 | 子应用 |
子应用从 /detail 点击到 /home | URL 不变化 | 页面变为 home |
点击后退 | URL 不变化 | 页面变为 detail |
如果在主应用使用 pushState
同步路由,则:
行为 | 主应用 | 子应用 |
子应用从 /detail 点击到 /home | URL 变为 /cow/home | 页面变为 home |
点击后退 | URL 变为 /cow/detail | 页面不变化 |
点击后退 | URL 不变化 | 页面变为 detail |
使用 replaceState
,则:
行为 | 主应用 | 子应用 |
子应用从 /detail 点击到 /home | URL 变为 /cow/home | 页面变为 home |
点击后退 | URL 变为 /cow/detail | 页面变为 detail |
所以,在主应用侧使用 replaceState
来覆盖当前的 URL,便达到了完美效果!
回顾一下本文:
最开始,鸟瞰路由问题在整个微前端技术体系中的位置。
理解前端路由的存在意义,明确路由同步的确是个问题。
从最简单自然的处理方式开始,挖掘出方案背后的问题。
提出针对问题的解法,代入;考察是否出现新问题。
如有,回到上一步;否则,完成解题。
当然,如果解题中使用了其他办法,比如沙箱的实现没使用 iframe,那么本文后续的进一步解法便不再成立。但是只要保证目标效果一致,具体实现可以百花齐放。
History API:
https://developer.mozilla.org/en-US/docs/Web/API/History_API
Joint Session History:
https://www.w3.org/TR/2012/WD-html5-20121025/history.html#joint-session-history
aliyun/alibabacloud-console-os:
https://github.com/aliyun/alibabacloud-console-os
aliyun/alibabacloud-console-widget:
https://github.com/aliyun/alibabacloud-console-widget
关注「Alibaba F2E」
把握阿里巴巴前端新动向