一、什么是微前端?
我们先来看两个实际的场景:
1、复用别的的项目页面
如果我们的项目需要开发某个新的功能,而这个功能另一个项目已经开发好,我们想直接复用时。注意:我们需要的只是别人项目的这个功能页面的「内容部分」,不需要别人项目的顶部导航和菜单。
一个比较笨的办法就是直接把别人项目这个页面的代码拷贝过来,但是万一别人不是 vue
开发的,或者说 vue
版本、UI
库等不同,以及别人的页面加载之前操作(路由拦截,鉴权等)我们都需要拷贝过来,更重要的问题是,别人代码有更新,我们如何做到同步更新。
长远来看,代码拷贝不太可行,问题的根本就是,我们需要做到让他们的代码运行在他们自己的环境之上,而我们对他们的页面仅仅是“引用”。
这个环境包括各种插件( vue
、 vuex
、 vue-router
等),也包括加载前的逻辑(读 cookie
,鉴权,路由拦截等)。私有 npm
可以共享组件,但是依然存在技术栈不同/UI库不同等问题。
2、巨无霸项目的自由拆分组合
- 代码越来越多,打包越来越慢,部署升级麻烦,一些插件的升级和公共组件的修改需要考虑的更多,很容易牵一发而动全身
- 项目太大,参与人员越多,代码规范比较难管理,代码冲突也频繁。
- 产品功能齐全,但是客户往往只需要其中的部分功能。剥离不需要的代码后,需要独立制定版本,独立维护,增加人力成本。
举个例子,你们的产品有几百个页面,功能齐全且强大,客户只需要其中的部分页面,而且需要你们提供源码,这时候把所有代码都给出去肯定是不可能的,只能挑出来客户需要,这部分代码需要另外制定版本维护,就很浪费。
二、常见微前端方案
微前端的诞生也是为了解决以上两个问题:
(1)复用(嵌入)别人的项目页面,但是别人的项目运行在他自己的环境之上。
(2)巨无霸应用拆分成一个个的小项目,这些小项目独立开发部署,又可以自由组合进行售卖。
使用微前端的好处:
(1)技术栈无关,各个子项目可以自由选择框架,可以自己制定开发规范。
(2)快速打包,独立部署,互不影响,升级简单。
(3)可以很方便的复用已有的功能模块,避免重复开发。
目前微前端主要有两种解决方案:iframe
方案和 single-spa
方案
1、iframe
方案
iframe
大家都很熟悉,使用简单方便,提供天然的 js/css
隔离,也带来了数据传输的不便,一些数据无法共享(主要是本地存储、全局变量和公共插件),两个项目不同源(跨域)情况下数据传输需要依赖 postMessage
。iframe
有很多坑,但是大多都有解决的办法:
(1)页面加载问题
iframe
和主页面共享连接池,而浏览器对相同域的连接有限制,所以会影响页面的并行加载,阻塞 onload
事件。每次点击都需要重新加载,虽然可以采用 display:none
来做缓存,但是页面缓存过多会导致电脑卡顿。「(无法解决)」
(2)布局问题
iframe
必须给一个指定的高度,否则会塌陷。
解决办法:子项目实时计算高度并通过 postMessage
发送给主页面,主页面动态设置 iframe
高度。有些情况会出现多个滚动条,用户体验不佳。
(3)弹窗及遮罩层问题
弹窗只能在 iframe
范围内垂直水平居中,没法在整个页面垂直水平居中。
解决办法1:通过与框架页面消息同步解决,将弹窗消息发送给主页面,主页面来弹窗,对原项目改动大且影响原项目的使用。
解决办法2:修改弹窗的样式:隐藏遮罩层,修改弹窗的位置。
(4)iframe
内的 div
无法全屏
弹窗的全屏,指的是在浏览器可视区全屏。这个全屏指的是占满用户屏幕。
全屏方案,原生方法使用的是 Element.requestFullscreen()
,插件:vue-fullscreen。当页面在 iframe
里面时,全屏会报错,且 dom
结构错乱。
(5)浏览器前进/后退问题
iframe
和主页面共用一个浏览历史,iframe
会影响页面的前进后退。大部分时候正常,iframe
多次重定向则会导致浏览器的前进后退功能无法正常使用。并且 iframe
页面刷新会重置(比如说从列表页跳转到详情页,然后刷新,会返回到列表页),因为浏览器的地址栏没有变化,iframe
的 src
也没有变化。
(6)iframe
加载失败的情况不好处理
非同源的 iframe
在火狐及 chorme
都不支持 onerror
事件。
解决办法1:onload
事件里面判断页面的标题,是否 404
或者 500
解决办法2:使用 try catch
解决此问题,尝试获取 contentDocument
时将抛出异常。
解决办法参考:stackoverflow上的问题:Catch error if iframe src fails to load
2、single-spa
微前端方案
spa
单页应用时代,我们的页面只有 index.html
这一个 html
文件,并且这个文件里面只有一个内容标签 <div id="app"></div>
,用来充当其他内容的容器,而其他的内容都是通过 js
生成的。也就是说,我们只要拿到了子项目的容器 <div id="app"></div>
和生成内容的 js
,插入到主项目,就可以呈现出子项目的内容。
<link href=/css/app.c8c4d97c.css rel=stylesheet> <div id=app></div> <script src=/js/chunk-vendors.164d8230.js> </script> <script src=/js/app.6a6f1dda.js> </script>
我们只需要拿到子项目的上面四个标签,插入到主项目的 HTML
中,就可以在父项目中展现出子项目。
这里有个问题,由于子项目的内容标签是动态生成的,其中的 img/video/audio
等资源文件和按需加载的路由页面 js/css
都是相对路径,在子项目的 index.html
里面,可以正确请求,而在主项目的 index.html
里面,则不能。
举个例子,假设我们主项目的网址是 www.baidu.com
,子项目的网址是 www.taobao.com
,在子项目的 index.html
里面有一张图片 <img src="./logo.jpg">
,那么这张图片的完整地址是 www.taobao.com/logo.jpg
,现在将这个图片的 img
标签生成到了父项目的 index.html
,那么图片请求的地址是 www.baidu.com/logo.jpg
,很显然,父项目服务器上并没有这张图
解决思路:
- 这里面的
js/css/img/video
等都是相对路径,能否通过webpack
打包,将这些路径全部打包成绝对路径?这样就可以解决文件请求失败的问题。 - 能否手动(或借助
node
)将子项目的文件全部拷贝到主项目服务器上,node
监听子项目文件有更新,就自动拷贝过来,并且按js/css/img
文件夹合并 - 能否像
CDN
一样,一个服务器挂了,会去其他服务器上请求对应文件。或者说服务器之间的文件共享,主项目上的文件请求失败会自动去子服务器上找到并返回。
通常做法是动态修改 webpack
打包的 publicPath
,然后就可以自动注入前缀给这些资源。
single-spa
是一个微前端框架,基本原理如上,在上述呈现子项目的基础上,还新增了 bootstrap
、 mount
、 unmount
等生命周期。
相对于 iframe
,single-spa
让父子项目属于同一个 document
,这样做既有好处,也有坏处。好处就是数据/文件都可以共享,公共插件共享,子项目加载就更快了,缺点是带来了 js/css
污染。
single-spa
上手并不简单,也不能开箱即用,开发部署更是需要修改大量的 webpack
配置,对子项目的改造也非常多。
三、qiankun
方案
qiankun
是蚂蚁金服开源的一款框架,它是基于 single-spa
的。他在 single-spa
的基础上,实现了开箱即用,除一些必要的修改外,子项目只需要做很少的改动,就能很容易的接入。如果说 single-spa
是自行车的话,qiankun
就是个汽车。
微前端中子项目的入口文件常见的有两种方式:JS entry
和 HTML entry
纯 single-spa
采用的是 JS entry
,而 qiankun
既支持 JS entry
,又支持 HTML entry
。
JS entry
的要求比较苛刻:
(1)将 css
打包到 js
里面
(2)去掉 chunk-vendors.js
,
(3)去掉文件名的 hash
值
(4)将 single-spa
模式的入口文件( app.js
)放置到 index.html
目录,其他文件不变,原因是要截取 app.js
的路径作为 publicPath
APP entry | 优点 | 缺点 |
---|---|---|
JS entry |
可以配合 systemJs ,按需加载公共依赖( vue , vuex , vue-router 等) |
需要各种打包配置配合,无法实现预加载 |
HTML entry |
打包配置无需做太多的修改,可以预加载 | 多一层请求,需要先请求到 HTML 文件,再用正则匹配到其中的 js 和 css |
其实 qiankun
还支持 config entry
:
{ entry: { scripts: [ "app.3249afbe.js" "chunk-vendors.75fba470.js", ], styles: [ "app.3249afbe.css" "chunk.75fba470.css", ], html: 'http://localhost:5000' } }
建议使用 HTML entry
,使用起来和 iframe
一样简单,但是用户体验比 iframe
强很多。
qiankun
请求到子项目的 index.html
之后,会先用正则匹配到其中的 js/css
相关标签,然后替换掉,它需要自己加载 js
并运行,然后去掉 html/head/body
等标签,剩下的内容原样插入到子项目的容器中 :
使用 qiankun
的好处:
(1)qiankun
自带 js/css
沙箱功能,singles-spa
可以解决 css
污染,但是需要子项目配合
(2)single-spa
方案只支持 JS entry
的特点,限制了它只能支持 vue
、 react
、 angular
等技术开发的项目,对一些 jQuery
老项目则无能为力。qiankun
则没有限制
(3)qiankun
支持子项目预请求功能。
1、js
沙箱
js/css
污染是无法避免的,并且是一个可大可小的问题。就像一颗定时炸弹,不知道什么时候会出问题,排查也麻烦。作为一个基础框架,解决这两个污染非常重要,不能仅凭“规范”开发。
js
沙箱的原理是子项目加载之前,对 window
对象做一个快照,子项目卸载时恢复这个快照,如图:
那么如何监测 window
对象的变化呢,直接将 window
对象进行一下深拷贝,然后深度对比各个属性显然可行性不高,qiankun
框架采用的是ES6
新特性,proxy
代理方法。但是 proxy
是不兼容 IE11
的,为了兼容,低版本 IE
采用了 diff
方法:浅拷贝 window
对象,然后对比每一个属性。
2、css 沙箱
qiankun
的 css
沙箱的原理是重写 HTMLHeadElement.prototype.appendChild
事件,记录子项目运行时新增的 style/link
标签,卸载子项目时移除这些标签。
single-spa
方案中我用了换肤的思路来解决 css
污染:首先 css-scoped
解决大部分的污染,对于一些全局样式,在子项目给 body/html
加一个唯一的 id/class
(正常开发部署用),然后这个全局的样式前面加上这个 id/class
,而 single-spa
模式则在 mount
周期给 body/html
加上这个唯一的 id/class
,在 unmount
周期去掉,这样就可以保证这个全局 css
只对这个项目生效了。
这两个方案的致命点都在于无法解决多个子项目同时运行时的 css
污染,以及子项目对主项目的 css
污染。
虽然说两个项目同时运行并不常见,但是如果想实现 keep-alive
,就需要使用 display: none
将子项目隐藏起来,子项目不需要卸载,这时候就会存在两个子项目同时运行,只不过其中一个对用户不可见。
css
沙箱还有个思路就是将子项目的样式局限到子项目的容器范围内生效,这样只需要给不同的子项目不同的容器就可以了。但是这样也会有新的问题,子项目中 append
到 body
的弹窗,样式就无法生效。所以说样式污染还需要制定规范才行,约定 class
命名前缀。
四、微前端方案实践
改造已有的项目为qiankun子项目,由于我们是 vue
技术栈,所以我就以改造一个 vue
项目为例说明,其他的技术栈原理是一样的。
1、在 src
目录新增文件 public-path.js
if (window.__POWERED_BY_QIANKUN__) { __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; }
2、修改 index.html
中项目初始化的容器,不要使用 #app
,避免与其他的项目冲突,建议换成项目 name
的驼峰写法
3、修改入口文件 main.js
:
import './public-path'; import Vue from 'vue' import App from './App.vue' import VueRouter from 'vue-router' import store from './store'; Vue.use(VueRouter) Vue.config.productionTip = false let router = null; let instance = null; function render(parent = {}) { const router = new VueRouter({ // histroy模式的路由需要设置base,app-history-vue根据项目名称来定 base: window.__POWERED_BY_QIANKUN__ ? '/app-history-vue' : '/', mode: 'history', // hash模式不需要上面两行 routes: [] }) instance = new Vue({ router, store, render: h => h(App), data(){ return { parentRouter: parent.router, parentVuex: parent.store, } }, }).$mount('#appVueHistory'); } //全局变量来判断环境,独立运行时 if (!window.__POWERED_BY_QIANKUN__) { render(); } export async function bootstrap() { console.log('vue app bootstraped'); } export async function mount(props) { console.log('props from main framework', props); render(props.data); } export async function unmount() { instance.$destroy(); instance = null; router = null; }
主要改动是引入修改 publicPath
的文件和 export
三个生命周期。
注意:
webpack
的publicPath
值只能在入口文件修改,之所以单独写到一个文件并在入口文件最开始引入,是因为这样做可以让下面所有的代码都能使用这个。- 路由文件需要
export
路由数据,而不是实例化的路由对象,路由的钩子函数也需要移到入口文件。 - 在
mount
生命周期,可以拿到父项目传递过来的数据,router
用于跳转到主项目/其他子项目的路由,store
是父项目的实例化的Vuex
。
4、修改打包配置 vue.config.js
:
const { name } = require('./package'); module.exports = { devServer: { headers: { 'Access-Control-Allow-Origin': '*', }, }, // 自定义webpack配置 configureWebpack: { output: { library: `${name}-[name]`, libraryTarget: 'umd',// 把子应用打包成 umd 库格式 jsonpFunction: `webpackJsonp_${name}`, }, }, };
注: 这个 name
默认从 package.json
获取,可以自定义,只要和父项目注册时的 name
保持一致即可。
这个配置主要就两个,一个是允许跨域,另一个是打包成 umd
格式。为什么要打包成 umd
格式呢?是为了让 qiankun
拿到其 export
的生命周期函数。我们可以看下其打包后的 app.js
就知道了:
root
在浏览器环境就是 window
, qiankun
拿这三个生命周期,是根据注册应用时,你给的 name
值,name
不一致则会导致拿不到生命周期函数
五、子项目开发的一些注意事项
1、所有的资源(图片/音视频等)都应该放到 src
目录,不要放在 public
或 者static
资源放 src
目录,会经过 webpack
处理,能统一注入 publicPath
。否则在主项目中会404。
参考:vue-cli3的官方文档介绍:何时使用-public-文件夹
暴露给运维人员的配置文件 config.js
,可以放在 public
目录,因为在 index.html
中 url
为相对链接的 js/css
资源,qiankun
会给其注入前缀。
2、请给 axios
实例添加拦截器,而不是 axios
对象
后续会考虑子项目共享公共插件,这时就需要避免公共插件的污染
// 正确做法:给 axios 实例添加拦截器 const instance = axios.create(); instance.interceptors.request.use(function () {/*...*/}); // 错误用法:直接给 axios 对象添加拦截器 axios.interceptors.request.use(function () {/*...*/});
3、避免 css
污染
组件内样式的 css-scoped
是必须的。
对于一些插入到 body
的弹窗,无法使用 scoped
,请不要直接使用原 class
修改样式,请添加自己的 class
,来修改样式。
.el-dialog{ /* 不推荐使用组件原有的class */ } .my-el-dialog{ /* 推荐使用自定义组件的class */ }
4、谨慎使用 position:fixed
在父项目中,这个定位未必准确,应尽量避免使用,确有相对于浏览器窗口定位需求,可以用 position: sticky
,但是会有兼容性问题(IE不支持)。如果定位使用的是 bottom
和 right
,则问题不大。
还有个办法,位置可以写成动态绑定 style
的形式:<div :style="{ top: isisQiankun ? '10px' : '0'}">
5、给 body
、 document
等绑定的事件,请在 unmount
周期清除
js
沙箱只劫持了 window.addEventListener
,使用 document.body.addEventListener
或者 document.body.onClick
添加的事件并不会被沙箱移除,会对其他的页面产生影响,请在 unmount
周期清除
原文链接:https://juejin.cn/post/6844904185910018062,作者:沉末_