微前端最早由ThoughtWorks在2016年提出,其主要思想是在前端引入类似后端微服务架构的理念,将庞大的巨石应用拆分成多个独立的应用(以下称微应用)。每个独立应用都可独立开发、测试和部署,然后在通过一个容器应用(以下称主应用)将所有独立应用组合起来呈现个用户。
实现微前端的方式有很多种,我们熟知的iframe其实就属于微前端,其具有自带沙箱隔离,开发分离等优点,但是同时也有很多额外的问题,重复加载脚本、SEO较差、多个滚动条等等问题导致其无法广泛应用。而现代SPA框架的兴起给微前端带来了新的福音,结合SPA路由形式去加载指定的子应用,如此就可以实现一个微前端的架构。
不过实现微前端架构还需要解决以下几个问题:
- 如何实现路由分发应用。
- 如何控制微应用加载、卸载。
- 微应用通过什么形式传递给主应用。
- 微应用之间的如何进行数据共享。
- 微应用之间如何做到互不影响。
一、通过路由分发应用
在SPA中,路由是通过框架来进行分发的,框架通过路由分发指定到具体的某个组件进行展示;在微前端架构中,微前端承接了框架的工作,通过路由去匹配对应的应用,在由应用去分发到对应的组件上。
从实现方式上来看,微前端的路由设计与前端路由解决方案如react-router、vue-router等也并无较大的区别,都是通过劫持路由进行实现,简单的代码实现:
/* 处理更新微应用方法 */ function reroute () { // TODO: } /* 监听路由变化,触发更新微应用方法 */ window.addEventListener('hashchange', reroute) window.addEventListener("popstate", reroute); window.history.pushState = patchedUpdateState(window.history.pushState) window.history.replaceState = patchedUpdateState(window.history.replaceState) /* 增强pushState和replaceState */ function patchedUpdateState (updateState) { return function (...args) { // 当前url const urlBefore = window.location.href; // pushState or replaceState 的执行结果 const result = Reflect.apply(updateState, this, args) // 执行updateState之后的url const urlAfter = window.location.href if (urlBefore !== urlAfter) { reroute() } return result } }
以上可以看到监听了hashchange和popstate的变化,同时通过装饰器模式向pushState和replaceState方法添加了判断逻辑,确保当路由发生变化的时候执行reRoute方法去处理更新微应用状态,那么如何处理微应用状态呢?
二、通过生命周期控制应用加载、卸载
我们先来看一下微应用的注册结构,根据结构反看实现比较容易理解。
// 微应用注册结构 { // 微应用名称 name: 'app1', // 微应用加载函数,是一个promise app: loadApp('http://localhost:8081', 'app1'), // 当路由满足条件时,去挂载子应用 activeWhen: location => location.pathname.startsWith('/app1'), // 传递给微应用的对象 customProps: {} }
根据SPA框架我们能够发现,框架中通过生命周期用来控制组件加载卸载,Single-spa也是从中获得的灵感,用生命周期来控制微应用,根据状态可以分为一下几种状态:
// 子应用注册以后的初始状态 const NOT_LOADED = 'NOT_LOADED' // 表示正在加载子应用源代码 const LOADING_SOURCE_CODE = 'LOADING_SOURCE_CODE' // 执行完 app.loadApp,即子应用加载完以后的状态 const NOT_BOOTSTRAPPED = 'NOT_BOOTSTRAPPED' // 正在初始化 const BOOTSTRAPPING = 'BOOTSTRAPPING' // 执行 app.bootstrap 之后的状态,表是初始化完成,处于未挂载的状态 const NOT_MOUNTED = 'NOT_MOUNTED' // 正在挂载 const MOUNTING = 'MOUNTING' // 挂载完成,app.mount 执行完毕 const MOUNTED = 'MOUNTED' const UPDATING = 'UPDATING' // 正在卸载 const UNMOUNTING = 'UNMOUNTING' ...
微应用在注册时会被统一加入一个status参数用来标记当前应用状态,初始值为NOT_LOADED,然后根据加载的不同时机以及微应用的状态进行不同的状态区分,可以分为三大类(实际情况会更多):待加载、待挂载和待卸载,根据三种状态执行不同的操作。
除此之外,为了能够将微前端生命周期与微应用声明周期关联起来,我们就需要获取到微应用的声明周期供我们使用,所以在子应用中我们采用umd这种兼容性更好的打包模块格式打包,在子应用中export生命周期挂载在global上,然后我们根据微应用的注册结构的app参数就可以拿到微应用暴露出来的生命周期,将微应用的生命周期一同加入微应用配置文件中就具备了所有可操作条件,下面看一下如何处理不同状态的微应用:
function reroute(){ // 将微应用根据状态分为三类 const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges() if (isStarted) { performAppChanges() } else { loadApps() } function loadApps () { appsToLoad.map(toLoad) } function performAppChanges () { // 卸载 appsToUnmount.map(toUnmount) // 初始化 + 挂载 appsToMount.map(tryToBoostrapAndMount) } }
getAppChanges方法通过循环区分出三种状态微应用,然后根据isStarted用来判断是否微应用已经加载,对于未加载的微应用进行加载,已经加载的子应用根据状态执行对应微应用配置文件中的生命周期加载、卸载。
至此,我们具备了一个微前端的基本形式。
三、微应用通过什么形式提供渲染入口
微前端架构中要想做到技术无关和独立部署,那么采用运行时引入肯定是最佳的方案,但是微应用要提供什么形式的资源作为入口提供给主应用呢?目前有两种方案,一种是通过js引入,single-spa就是采用的该方法,一种是根据html引入,qiankun采用的方法。下面对比一下两种方案:
通过js方式引入,通常需要微应用将资源打成一个 entry script,这种情况下就会出现颇多限制,比如单个js包过大,资源并行加载等特性也无法利用。同时在js 引入方案中,主框架需要在微应用加载之前构建好相应的容器节点,然后将获取到的微应用注入构建好的节点中完成渲染。
通过html形式引入,该方式较为灵活,主框架通过fetch html方式获取到子应用的静态资源,同时将 HTML document 作为子节点塞到主框架的容器中。这样不仅可以极大的减少主应用的接入成本,子应用的开发方式及打包方式基本上也不需要调整,而且可以天然的解决子应用之间样式隔离的问题。
四、微应用之间的数据共享
single-sap并不建议微应用之间大量共享数据,其认为如果两个应用中耦合数据过多,那么我们就需要考虑是否应该将它们拆分了,不过还是提供了共享的方式,具体可参考这里。
qiankun则是采用两种方式进行数据共享,对于主应用和微应用之间基于 props 以单向数据流的方式传递给子应用进行共享;对不同微应用间采用基于浏览器原生事件进行数据通信。
除此之外,我们还可以采用一些全局的存储方式进行数据共享,例如localStorage、sessionStorage等。
五、应用隔离
1、JS隔离
qiankun采用了沙箱的方式进行隔离,对于沙箱的具体实现可以去搜罗一下文章,也是一个很有意思的东西,主要说一下qiankun的沙箱,qiankun主要有两种沙箱,一种是对于不支持proxy的快照沙箱snapshotSandbox,一种是基于proxy的代理沙箱proxySandbox。
快照沙箱,整体思路是先将window对象copy给一个新的对象保存下来,然后该应用的所有操作都基于该新对象,所有的修改在新对象中进行,然后在实例销毁前与window进行对比,将更改的部分保留下来,然后在下次进入该实例是,将更改的部分通过保留的数据进行还原,以达到退出前的样子。
代理沙箱,主要是采用ES6的proxy特性,通过proxy我们可以获取到对象上所有的改变,主体思想不变,都是通过代理全局对象,监控所有修改,然后将所有改变缓存起来,等该实例在此进入的时候根据数据进行还原。不过通过proxy实现可以支持多实例同时运行。
2、CSS隔离
做到css隔离的实现方式有很多,比如最简单直接的办法通过约定css前缀的方式来避免样式冲突,每个微应用都拥有特定的前缀,该方案适用于新项目中,对于历史遗留项目,整体适配起来还是比较困难的。
qiankun通过fetch html方式引入微应用资源,此种方式可以天然的解决样式冲突问题,它会将微应用的所有资源进行解析分类,然后在进行引入,在微应用销毁时又会整体移除css tree,可以做到相互不影响,有兴趣的大家可以看一下 import-html-entry库,代码不多,很精简。
六、总结
根据以上可以发现,微前端其实主要做了两件事情,一是处理加载微应用;二是通过生命周期管理各个微应用。重点在于主应用提供的各种配置、入口,然后具体由微前端框架进行操作,在特定的时间节点执行微应用的生命周期函数。
相关链接
❝
Time is fair, because it gives everyone 24 hours.
❞
本文使用 mdnice 排版