问题背景和期望达到的效果
领域问题背景
对于点进本文的读者而言,相信大家已经很熟悉微前端需要解决的问题场景:
- 巨石应用的分治开发。
- 新老项目线上并存。
- 三方系统集成。
- ……
以上问题场景的具体案例这里不再展开。值得注意的是,根据场景的需求不同,微前端的实施方案会有所细节差异,比如:
- 三方系统集成的场景下,方案必须要能兼容不同的技术栈;而对于阿里内部项目来说,一般只用考虑 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。这是因为我们通常希望把页面状态暴露给用户,以便用户能看到(了解当前状态),能输入(影响当前状态)。
所以,粗略地来说,前端路由必不可少,对开发者和用户来说都很重要。如此,就让我们从简单的路由调度方式拓展到更复杂的情况,最后实现一个尽可能完美的方案。
原文摘自《如何设计微前端中的主子路由调度》