# iceStack 架构设计
icestark (opens new window) 是一个面向大型系统的微前端解决方案,适用于以下业务场景:
- 后台比较分散,体验差别大,因为要频繁跳转导致操作效率低,希望能统一收口的一个系统内
- 单页面应用非常庞大,多人协作成本高,开发/构建时间长,依赖升级回归成本高
- 系统有二方/三方接入的需求
icestark 在保证一个系统的操作体验基础上,实现各个微应用的独立开发和发版,主应用通过 icestark 管理微应用的注册和渲染,将整个系统彻底解耦。
微前端 icestark 主要会根据路由变化对应用进行分发,包括应用生命周期管理、应用加载,通信、隔离,还有沙箱运行。框架应用去接入微前端的时候不用关心微应用相关的处理,核心只需要完成微应用的配置。框架应用里面处理微应用配置之外可能还会涉及到一些鉴权的逻辑或者应用埋点逻辑等业务上的实践方案。
# 设计理念
- 技术栈无关
- 中心化路由
- 开发体验一致
- 独立开发与部署
# 技术栈无关
一个微应用接入的时候我们并不关心它的技术栈是什么样的,不论是使用 React 还是 Vue,或者 Angular,甚至说它是一个上古的代码(jQuery),应用都能够被接入。 但为什么在实践上又推荐单一技术体系的技术栈统一呢?看上去是两个相悖的概念,但实际上我们的思考是微前端能够通过技术栈无关的能力,将一些独立的系统或者应用,都集成在一个系统中。在集成的过程中,更多的希望它能够去做一些技术上的统一,而不是不去做任何管控,让它野蛮生长。所以在微前端架构具体实践过程中,我们秉持的理念就是在单一地体系下,需要技术上的统一,即便当下基于成本考虑不去迁移,长远来看肯定是逐步收敛技术体系。
# 中性化路由
微前端方案中核心的一个能力 - 路由能力,在 icestark 当中,路由其实是一个中心化的管理,所有的路由信息都是在框架应用中维护,根据路由的变化去做路由的分发和管理。
# 开发体验一致
技术架构上引入一套微前端方案,并不会意味着要有很多新概念去学习,包括新的语法、构建逻辑,甚至整体的编码方式都发生变化,这是我们不期望看到的。所以在设计的时候,核心的一个命题就是低成本甚至零成本的迁移,开发者不需要额外去学习一些新的概念和流程,保持跟原先的开发逻辑一致。
# 独立开发与部署
在一定程度上会反映出上面提到的开发体验一致问题。之前的应用是独立开发、独立部署的,现在依旧保持原样,和微前端架构接入之前没有变化。
# 设计原理
# 入口规范
icestark 通过微应用入口字段的配置进行应用的渲染,因此这个字段 非常重要。针对不同的场景,icestark 也支持了多种入口配置形式。
url
- 适用于微应用入口资源比较确定,此时将这些资源地址按顺序拼成数组传给 icestark 即可。
const apps = [{
url: ['https://example.com/a.js', 'https://example.com/a.css'],
path: '/foo'
// ...
}]
2
3
4
5
entry
- 应用依赖的入口资源不确定:比如需要引入 vendor、或者不确定的 externals 资源、资源地址带 hash 等场景
- 应用默认需要依赖很多 DOM 节点:比如
jQuery
/Kissy
/Angular
等框架 - 支持直接解析 html 能简化兼容旧技术栈的成本,同时进一步简化 url 的配置成本。
- entry 对应
html url
, icestark 对entry
的处理包含以下步骤:- 通过
window.fetch
获取 entry 属性对应的 html 内容 - 解析 html 内容,框架将会进行解析处理:提取 js 信息,如果资源路径为相对地址,将根据 entry 地址进行补齐
- 将处理后的 html 内容插入 icestark 动态创建的节点
- 依次通过创建
script
标签按顺序引入 js 资源
- 通过
const apps = [{
entry: 'https://example.com/a.html',
path: '/foo'
// ...
}]
2
3
4
5
entryContent
- 当需要使用 entry 能力但是
html url
不支持前端跨域访问的情况,可以自行将 html 内容拿到,然后通过 entryContent 属性传递给 icestark。entryContent 对应的 htmlContent,也是类似的处理逻辑,仅仅去掉了主动 fetch 的过程。
const apps = [{
entryContent: '<!DOCTYPE html><html><body><script src=""></body></html>',
path: '/foo'
// ...
}]
2
3
4
5
render/component
- 组件直出,仅使用 React 的主应用支持,简化框架应用层子应用和子页面的配置成本。
// src/App.jsx
import React from 'react';
import { AppRouter, AppRoute } from '@ice/stark';
import BasicLayout from '@/layouts/BasicLayout';
export default class App extends React.Component {
render() {
return (
<BasicLayout>
<AppRouter>
<AppRoute
title="商家平台"
url={[
'//unpkg.com/icestark-child-seller/build/js/index.js',
'//unpkg.com/icestark-child-seller/build/css/index.css',
]}
/>
<AppRoute
path="/user"
//...
/>
</AppRouter>
</BasicLayout>
);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 路由匹配与劫持
路由劫持是微前端方案中比较重要的一块能力,如果不去劫持应用的路由,就无法判定当前需要加载哪一个应用资源,也无法决定渲染什么界面。当我们访问到框架应用页面时,icestark 内部会去做一个路由的分发。
# 兜底路由
如果在微应用架构里面去设置了 path 为 /
的一个微应用,那它将整个系统的一个兜底路由,所有不匹配已注册的路由配置都会由兜底路由进行渲染。
兜底路由一般情况都会用来渲染通用页面,比如跟框架应用有比较强的耦合页面,比如登陆页面, 404 页面或者说退出登录的页面。所以实践上面我们也将兜底路由作为框架应用自身路由的渲染。
# 路由劫持
为了能够让 icestark 响应页面路由的变化,并对相应的微应用进行加载,icestark 对两类路由事件进行了劫持:
history API
中的popstate
和hashChange
window
上的路由事件pushState
和replaceState
(通常在浏览器上进行前进后退操作的时候会触发)。
为什么能够完成这样的路由分发操作?通过一个 url 的变化,内部究竟是怎么劫持处理的,如何判断出需要加载的是注册的哪个应用?这个就涉及到我们的路由劫持原理:
icestark 对两类路由事件进行了劫持,一类为 history API 中的 popstate
和 hashChange
,另一类是 window 上的路由事件 pushState
和 replaceState
,这两个事件在浏览器上进行前进后退操作的时候会触发。一旦应用间发生跳转,通过上述事件的劫持能够拿到对应的路由信息,再根据路由的匹配来决定哪个微应用进行挂载。
应用内跳转:一个微应用可能会有多个路由设置,如果在没有发生应用间跳转的情况下,由于匹配到的是当前的微应用,所以不会再次加载资源,内部路由跳转逻辑则根据微应用自身路由配置决定渲染。
应用间跳转:如果整个框架应用的微应用配置发生卸载,这个时候将会将劫持的内容都会给移除,恢复到原始状态,这样就完成了整个应用从路由基础到 url 变化监听再到微应用加载的一个过程。
# 微应用加载渲染
通常来讲,我们在做一个 SPA 应用的时候是不需要关心其加载、卸载的时机的。因为应用资源加载完肯定会加载,而在应用卸载时,要么是整个应用跳转了,要么就是整个浏览器都关掉了。
但是在微前端的架构里面,就需要知道微应用的卸载逻辑,单单从 React 这个场景来说,如果不去定义它的卸逻辑载,在微应用切换的时候,很可能有很多的循环执行的事件、组件 unmount 卸载生命周期的函数都无法触发,这会给其他应用的执行带来一些副作用。
所以除了核心的渲染逻辑之外,还要关心微应用的卸载逻辑。注册的生命周期会跟资源加载的逻辑配合,最终去完成微应用的渲染或切换。
微应用在加载的时候,中间会去做一层资源缓存判断,如果资源没有缓存,icestark 会根据它注册到框架应用的配置信息进行资源分析。一般情况下资源会有两种配置方式:
- 一种是
url
方式,里面可能包括 JS 资源、CSS 资源 - 另一种是
html entry
的方式,html
方式涉及到的资源会更多。可能会有独立的 JS、独立的CSS,甚至会有一些内联的 script 标签和 style 标签。完成资源加载后,icestark 会将这些资源插入到框架应用中。
在资源标签插入之前会对应用的资源进行标识,因为微应用切换的时候需要知道应用关联了哪些资源,通过资源标识能够正确找到对应的资源信息。标识完之后,最终走到应用的挂载流程,也就是执行注册的 registerAppEnter 生命周期中的逻辑。 应用卸载相对来说就会比较简单。根据应用的资源标识找到所有需要卸载的资源。并执行 registerAppLeave 生命周期中注册的函数,这样就完成了整个微应用的卸载。
另外微应用资源加载过程中有一个资源缓存的判断,在一些业务场景下面,会遇到一些低端性能的场景,如果每一次都去重新加载资源进行执行,这对浏览器的性能会造成很大的负担。
所以我们中间加了一个资源缓存的过程。让已加载的资源和已执行的逻辑保存在内存,每次进入已缓存的微应用的时候,立即去执行缓存的微应用挂载函数,免去了资源加载和执行的过程。 但是这个过程中会一个问题,如果说微应用的资源不卸载,那其实会造成资源污染,包括 CSS 的污染跟 JS 的污染。所以这块能力的主动权交给了开发者,如果需要将微应用进行缓存,那就需要正确的评估其他接入微应用,是否会被污染。
如何将子应用的 bundle 渲染到指定节点?
框架应用有系统的 Layout,我们需要将子应用渲染到 Layout 里面,但是单页面应用都是直接通过 ReactDOM.render(<App />, document.getElementById('#root'))
的方式渲染,如果直接执行那么渲染的位置是无法被控制的,于是 icestark 为子应用提供了一个 getMountNode()
的 API 保证子应用能够渲染到指定的节点里。
如何加载 Vite 应用?
Vite 会默认打包出符合标准的 ES modules 的脚本资源。然而,在 icestark 中需要依赖微应用导出 生命周期函数 来渲染微应用。使用 <script>
标签加载 ES modules 脚本的一个难题在于无法获取微应用导出的生命周期函数。基于这个考虑,实际实现中是通过 Dynamic Import 来加载脚本:
// 为了兼容旧版浏览器,通过 new Function() 将其包裹
const dynamicImport = new Function('url', 'return import(url)');
const { mount, unmount } = await dynamicImport(url);
2
3
4
如上 icestark 应用加载流程图,除了能支持 IIFE / UMD
规范的微应用之外,icestark 支持了 ES modules
规范的应用加载,并通过 import
类型标识。
# 应用间通信
框架应用跟微应用之间,或者说是微应用跟微应用之间,是不是能够去做一些通信或者做一些事件监听?
其实从微前端的设计原则上来说,我们并不希望为微应用太多地去依赖框架应用或者其它微应用提供的能力。之前遇到有一些场景,有些开发者希望把一些很重的逻辑,比如通用的 utils 逻辑,通过应用间的通信方式,实现不同应用间的函数共享。技术上是行得通的,但这样的设计会对应用的维护性造成很大影响。
icestark 提供了一个应用通信机制,在实际开发过程中推荐应该更加轻量的去使用。比如说这通信机制仅仅让框架应用和微应用的多语言设置保持一致,多语言设置发生切换时,微应用能够监听到这个变化。另外一个就是应用间的事件通信,大部分场景是微应用系统通知框架应用去主动获取数据。基于这样的场景,我们可以利用应用通信的能力,来完成一些轻量的通知。
通过 @ice/stark-data
这个包可以很简单的实现应用间通信,比如全局切换语言微应用响应的场景。@ice/stark-data
支持状态共享和事件监听响应两种方式。核心其实是一个 EventBus
的机制,框架应用跟微应用之间的通讯,以 window
这样一个全局变量作为桥梁。这样不管是微应用添加的事件或数据,还是框架应用添加的事件或数据都可以访问到。核心只有两个 API:store(全局变量管理中心) 和 event(全局事件管理中心)。
对于主应用和微应用,运行时都共享了当前页面的 location、Cookie、LocalStorage、window 等全局信息,因此应用间的通信,也可以通过这些方案很简单的实现。
- 子应用页面切换参数流转
- 中英文切换,切换按钮在框架应用,监听事件在子应用
- 登录信息前端互通,子应用确认框架应用登录后,前端不再重复发起登录请求相关逻辑;登录用户信息保存在框架应用中,子应用获取用户数据
- 框架应用顶部有 ”消息“ 展示入口,子应用内有阅读消息的能力,阅读完消息后需要通知框架应用刷新“消息”展示信息
# 微前端隔离
icestark 当下的方案里,无论是主应用还是微应用都是直接在页面里执行,本质上不存在隔离机制,针对这个问题我们一方面通过一些规范来保证污染问题,一方面也在尝试更加彻底的沙箱机制,如果你的微应用都是二方接入,那我们推荐直接通过规范约束即可,如果存在三方接入这种不可控的场景,建议还是通过 iframe 的方式嵌入。
- 大部分业务没有三方接入需求,支持非常彻底的沙箱机制没有太大意义,至少在我们目前落地的业务中还没有出现相互污染的问题
- 对于可控的二方应用接入,我们推荐进行一些规范约定即可,比如不要污染全局变量、定时器及时清除、CSS 样式尽量通过前缀或者 CSS Modules 做隔离
- 对于不可控的三方应用,建议暂时先通过 iframe 的方式接入
- 沙箱机制有机会通过 Shadow DOM 和 Web Worker 之类的方案解决
# 样式隔离
页面运行时同时只会存在一个微应用,因此多个微应用不存在样式相互污染的问题,但是主应用和微应用是同时运行的,因此这两者中间可能会存在一些样式相互污染,针对这个问题,我们目前推荐「通过约定避免微应用与主应用的样式相互污染」的方案,同时也在尝试 Shadow DOM 的方案。常见的样式隔离方案:
使用 CSS Modules 方案管理样式
无论是主应用还是微应用,直接通过 CSS Modules 的方案管理自身可控的样式,这样基本杜绝了两者样式冲突的问题。
主应用自定义基础组件 prefix:
除了自身可控的样式,应用中还会有一些全局样式,比较典型的就是类似 next 这种基础组件的样式,如果主应用和微应用使用了不同版本的 next,则很容易造成样式相互污染,这种场景推荐在主应用中将基础组件的前缀统一改掉,比如将
next-
改为next-icestark-
,这个能力已在主应用模板中内置,具体可参考相关代码。微应用避免产生全局样式
对于类似
normalize.css
这种全局重置样式,推荐统一通过主应用引入,微应用尽量避免产生全局性质的样式,因为这样在切换微应用时可能会因为全局样式差异产生一些抖动。Shadow DOM
如果将微应用渲染到 Shadow DOM 中,那么微应用产生的所有样式都不会污染到全局,事实上在我们试验的过程中的确是这样的。
比如如果使用的基础依赖的组件库,并没有设计让 Dialog 等弹出层在指定的 dom 节点中插入结构的话,弹出层都是会逃离你当前的 shadow DOM。逃离之后,它就是一个无样式的弹框。这种无样式的弹框对于业务上来说是不可以接受的,因此弹框逻辑需要去做一些兼容,更甚至需要对底层组件去做改造。大部分类似 Dialog 组件的实现都是在 body 下创建一个容器节点,但是 Shadow DOM 里 Dialog 的样式无法作用到全局,因此展示出来 Dialog 就是无样式的,在这个问题上我们还在尝试,比如类似 Dialog 组件的实现能够进行优化:判断自身是否在 Shadow DOM 里,如果是的话则将容器节点创建到 Shadow DOM 里,否则创建到 body 节点下。
在 React 场景下,shadow DOM 的使用会涉及到事件机制的问题,因为React 的事件机制是代理到 document 的,但基于 shadow DOM 处理的话,它可能会阻断事件到它的 host 层,也就是你渲染 shadow DOM 的那一层。虽然说社区也有对应的包去做一些兼容处理,但它对业务上来说还是会有一些实现成本。
除此之外还包括其它的问题。比如 CSS @font-face,或者说一些字体属性,svg 都会有一个不兼容的场景。
实际业务实践:
业务代码中的样式隔离
- 推荐通过
CSS Module
的方式,能够自动生成hash
后缀的样式名,基于每个不同的应用构建出来的样式,在天然上就能够做到隔离。
- 推荐通过
基础组件样式隔离
- 基础组件能够支持
CSS prefix
的方式,可以为所有样式添加一个前缀,在实践过程中将框架应用的前缀和微应用前缀进行区分,来完成样式的隔离。如果有不支持CSS prefix
的样式,我们也能够借助社区PostCSS
的能力给组件样式加上namespace
,框架应用跟微应用通过不同的namespace
进行样式隔离。
- 基础组件能够支持
# 脚本隔离
相对于样式污染,JS 污染的危害性更高,在目前的方案下,如果微应用想要恶意污染的话基本是无法杜绝的,因此针对这种不可控的微应用建议还是通过 iframe 的方式接入。针对可控的二方应用,正常书写代码是不会有问题的,针对一些特殊情况通过一些规范规避。
微应用避免改变全局状态
比如改变全局变量 window/location 的默认行为,通过 document 操作 Layout 的 DOM,这些本身都是一些不推荐的做法。
主应用通过钩子记录并恢复全局状态
<AppRouter
onAppEnter={(appConfig) => {
// 按需记录全局状态
}}
onAppLeave={(appConfig) => {
// 按需恢复全局状态
}}
>
// {...}
</AppRouter>
2
3
4
5
6
7
8
9
10
多个应用的 bundle 多次执行的时候很容易对全局变量造成污染,icestark 中通过 proxy
的沙箱机制实现了脚本的隔离。
Prxoy 沙箱的基本原理是通过 with + new Function
的形式阻断代码中对于 window
的直接访问,并通过 Proxy 的方式拦截对于 window
变量的访问和写入,沙箱的隔离使代码不能直接访问到 window
对象,通过 ES6 的新特性 Proxy 可以定制 get/set 的逻辑,这样就能对 window 上的一些全局变量变化进行快照记录,以便微应用切换的时候进行恢复。
另外像一些应用初始化时,会在 window
上面设置 setTimeout
、setInterval
,如果在卸载阶段没有很好的处理,将会影响到下一个挂载微应用的执行。所以在沙箱中针对这类方法进行了特殊处理,在沙箱挂载前对相应的方法进行劫持,在卸载的时候,再对它进行恢复。
# 三方隔离
对于不授信的三方最简单最安全的隔离方式其实是 iframe。在 icestark 中可以简单定义好基准路由 path
,再通过自定义渲染的方法 render
将 iframe 相关的内容渲染出来。
# 微模块
微应用的更小粒度,通常是一个模块或页面,跟页面路由无关,可以随处挂载,也会出现多个微模块 (opens new window)同时渲染运行。
上面提到的一些技术方案、技术架构,以及解决思路,更多的是以加载一个微应用的方式,它的核心解的问题,就是把单个的 SPA 应用去接入框架应用中。那微模块又是怎么样的场景?下面是微模块的 3 大应用场景:
多模块共存
实现一个多 tab 方案。在原有已加载的微应用基础上,新开一个 tab 页面,里面的内容又是独立的 bundle 资源渲染出来的。
比如多页签的场景,其交互的特点决定了同一路由下存在的多个独立功能模块的诉求。如果每个 Tab 页签下都是一个子应用,并且包含对路由的响应,那意味着一旦路由变化,页签下面的子应用状态将变得不可掌控。而通过微模块的方案,可以便捷地实现多个应用共存,就像是渲染一个独立组件一样控制渲染应用。在结合研发框架的体系下,icestark 也可以便捷地利用 Static Router 的特性,将一个 SPA 应用作为一个独立模块进行渲染,从而大大降低业务上落地的难度。
模块组合搭建
一个页面里面会有信息模块,表单模块,以及列表模块。在一些对外输出复用的场景中,如果直接接入整个页面,其通用性并不是特别强,但如果各个模块能够进行自由组合,就可以按需组合出不同需求的页面。
动态渲染模块
页面的内容由接口返回的数据决定。数据中会给出需要渲染的模块内容,比如无线的搭建场景,其实也是一个微模块的应用场景。
将已有项目改造为微模块的方式与 微应用 (opens new window) 类似,主要包含两步:
- 应用入口导出生命周期函数
- 将模块构建为 UMD 产物
icestark 对于微模块的应用场景上会有一个明确的定义,微模块其实是不会再去耦合路由的。之前提到的微应用的内部基本上是一个 SPA 它至少有一个路由或者是一个页面,但是微模块的使用上我们希望尽量简单,因为一旦多个模块都大量耦合路由的话,这会使路由处理变得复杂。
在模块的标准上面,微模块是以 UMD 的方式直接打包,通过这种标准模式打包,即便是以 npm 包的形式也可以正常使用。在微模块内部除了默认导出模块方法外,还需要定义挂载(mount)和卸载(unmount)的生命周期。
微模块的应用场景其实是对微应用的一个补充,它更适用于更加细粒度的功能拆分和动态搭建的场景。
淘宝大型应用架构中的微前端方案 (opens new window)
← 微前端的沙箱设计 iframe 微前端方案 →