一、微前端
后端微服务已经流行很久了,这块相关的内容就不做介绍了。说到微前端,其实微前端和微服务的设计理念大致一样,都是希望将某个单一的单体应用,转化为多个可以独立运行、独立开发、独立部署、独立维护的服务或者应用的聚合,从而满足业务快速变化及分布式多团队并行开发的需求。
二、iFrame
看这需求,最先想到的必然是 iFrame了,iFrame 可以创建一个全新的独立的宿主环境,iFrame 的页面和父页面是分开的,作为独立区域而不受父页面的 CSS 或者全局的 JavaScript 影响,在没有其他替代方案前这可能是最方便且高效的办法之一了,把一个大项目拆分成为一个容器项目(容器项目,通常展示为菜单选项等)和众多个子项目,再在主项目中使用iFrame加载不同的子项目,就实现了一个简易版的微前端工程。这个方法实现简单,但是也存在很多缺点。
列举:
-
使用iFrame加载子项目时,当前浏览器的链接无任何变化,从而导致一系列问题,比如用户刷新下次进入页面时无法打开所需项目等等,对于这些问题当然也有办法去解决,通常使用postmessage方法来解决,会有一定的工作量。
容器项目监听message事件
window.addEventListener('message', function(event) {
// 接收到子项目发送的消息,然后使用pushState改变当前浏览器中URL
})
子项目
var state = 'url等相关信息'
window.parent.postMessage(state,'*')
-
每次使用iFrame打开子项目都需要重新加载资源,资源加载尽管有缓存,一般情况下有http强缓存,这种情况下还好,但如果是协商缓存或者无缓存都会耗费一定量的请求时间,从而导致子项目切换时过渡白屏效果明显,再往细一点说,资源加载完重新执行js也会有少量的耗时。
-
其他问题可以参考这个 https://www.yuque.com/kuitos/gky7yw/gesexv。
三、Single-spa
最近提到微前端,最应该想到就是这个库了,这也是目前微前端实现方案中最流行的一个。比如最新比较火热的qiankun微前端方案也都是基于Single-spa来二次封装,这也是本文章中最重要的一个实践步骤。
Single-spa是一个在前端应用程序中将多个javascript microfrontend集合在一起的框架。使用Single-Spa可以带来很多好处,例如:
-
在同一页面上使用多个框架而无需刷新页面 (React,AngularJS,Angular,Ember或您使用的任何东西)。
-
独立部署您的微前端。
-
使用新框架编写代码,而无需重写现有应用程序。
-
延迟加载代码可缩短初始加载时间。
上述介绍参考Single-Spa官网,看着很牛皮,但是想想iFrame好像也能给你做到,先不管这些往下看官网DEMO效果,查看DEMO资源可发现,打开页面后第一次切换tab会加载一个app.js,这个文件名一般都是一个项目的入口文件,也就是说切换tab会加载一个子项目,二次加载却不会再加载了,检查元素发现页面也没有iFrame等dom元素,再看看浏览器的url也是跟着变化。这就意味着iFrame方案的不足在这都能被完美的解决。
既然这么香,那该怎么使用呢?
这个库的实现原理先不做介绍,咱们在学习一个新框架或者库什么的不都是先学会怎么使用嘛?学会了怎么使用,真相就会慢慢浮现。
方案一:
一个项目,拆分多个入口文件,代码地址库只有一个。
容器项目入口文件如下(好好看代码中的注释!!)。
src/main.js:
// 导入该库
import { registerApplication, start } from 'single-spa'
// 容器项目用什么框架随意,vue、react等等都行,new Vue等代码就不展示了
// 注册一个子项目
registerApplication(
'app2', // 项目名为app2
() => import('src/app2/main.js'), // 导入该项目文件夹下的子项目入口文件
(location) => location.pathname.startsWith('/app2'), // 子项目打开触发条件,也就是检测到当浏览器中的location.pathname值开头为/app2时触发,然后加载该子项目
{some: 'value'} // 传给子项目的数据
)
// 注册第二个子项目
registerApplication(
'app1',
() => import('src/app1/main.js'),
(location) => location.pathname.startsWith('/app1'),
{some: 'value'}
)
start() // Single-spa启动
src/app2/main.js:
import Vue from 'vue'
import App from './App'
let instance = null
function render() {
instance = new Vue({
render: h => h(App),
}).$mount('#app2')
}
/**
* 子项目必须暴露三个钩子函数(bootstrap, mount,unmount)出去,并且钩子函数返回一个Promis.resove(),也可以直接使用async
*/
// 容器项目第一次调用子项目时会触发该钩子
export async function bootstrap(props) {
console.log('bootstrap', props)
}
// 子项目每次启动时会触发该钩子,props为容器项目传进来的数据
export async function mount(props) {
console.log('mount', props)
render()
}
// 切换子项目时,也就是该子项目被销毁时会触发该钩子
export async function unmount() {
// 销毁vue实例
instance.$destroy()
instance = null
}
上述代码中,容器项目中注册了两个子项目,当浏览器中url改变成****/app2时,匹配到app2这个子项目,Single-spa就会加载src/app2/main.js,加载完后调用对应子项目的生命周期钩子,也就完成了这一链路,这种方案可以用,但是呢好像所有项目都在一个代码仓库下,会造成一些维护问题,发版的话也不是独立的,修改了app2项目,发版却是发容器项目,这只是对于该库的一次初探,接下来看方案二。
方案二:
子项目拆分到独立代码仓库去,子项目每次修改后就打成一个npm包,然后给容器项目使用。
容器项目入口文件如下:
// 导入该库
import { registerApplication, start } from 'single-spa'
// 注册一个子项目
registerApplication(
'app2',
() => import('@project/app2'), // 导入子项目npm包
(location) => location.pathname.startsWith('/app2'),
{some: 'value'}
)
start() // Single-spa启动
该方案和方案一差不多,只不过把子项目抽离到单独的代码仓库,项目维护和迭代稍微好了一点,还有子项目打成npm包后,容器项目还得更新一次npm包和build一次再发版。显然方案二这样做还是不能做到独立发版,只是理解到一种新的项目维护方案。
方案三:
固定子项目打包出来的入口js名字,然后给容器项目使用。
容器项目入口文件如下:
// 导入该库
import { registerApplication, start } from 'single-spa'
// 动态执行js
function runScript(src) {
return new Promise((resove) => {
const el = document.createElement('script')
el.src = src
el.onload = function() {
resove()
}
document.body.appendChild(el)
})
}
// 注册一个子项目
registerApplication(
'app2',
() => {
await runScript('http://localhost:3001/app2.js') // 动态加载子项目的入口文件
return window.app2 // 子项目入口文件执行完挂bootstrap\mount\unmount载生命周期钩子到window上
}, // 导入子项目npm包
(location) => location.pathname.startsWith('/app2'),
{some: 'value'}
)
start() // Single-spa启动
子项目入口文件不变,还是参考方案一中的src/app2/main.js。
但是打包配置(webpack或者其他)需要修改,以webpack为例:
// 打包出来的文件名为app2.js,并且把项目入口文件中export的方法挂载到window.app2下面
output: {
filename: `app2.js`,
library: `app2`,
libraryTarget: 'window',
globalObject: 'this',
umdNamedDefine: true,
}
这样一操作,容器项目一样能成功加载到子项目的入口文件,并且执行子项目的钩子函数。各个项目终于能做到独立部署了,撒花~。但是…如果这样把子项目打包出来的入口文件名固定了,浏览器缓存这一块就有问题了,服务器设置协商缓存还好,换成强缓存如果子项目发版了,用户打开的可能还是上一个版本的内容。当然也你可以把动态加载的app2.js后面加上一个随机参数,保证用户每次加载最新的资源,但是这就再也是用不到缓存了。还有一个种办法可以解决这个缓存问题,参考这个项目,这个项目巧妙的利用
manifest.json
来提供子项目的入口文件,该方案完成后算是入门了,再看看下一个。
方案四:
使用SystemJs来实现动态导入子项目。
容器项目html文件如下:
html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
head>
<body class="vue1">
<div id="container-app">div>
<script src="system.js">script>
<script type="systemjs-importmap">
{"imports": {"@container": "http://localhost:3000/app.js", // 容器项目入口文件"@app2": "http://localhost:3001/app2.js" // 子项目入口文件
}
}script>
<script>
System.import('@container') // 加载容器项目script>
body>
html>
容器项目入口js文件:
// 导入该库
import { registerApplication, start } from 'single-spa'
registerApplication(
'app2',
() => System.import('@app2'),
(location) => location.pathname.startsWith('/app2'),
{}
)
start()
子项目入口文件不变,还是参考方案一中的src/app2/main.js
但是打包配置(webpack或者其他)需要修改,以webpack为例:
// 把子应用打包成umd模块,不然SystemJs调用不了
output: {
filename: `app2.js`,
library: `app2`,
libraryTarget: 'umd'
}
上述代码中,子项目的入口文件是写死的一样会有缓存问题,于是html文件中SystemJs那块可以改造成如下: