浅析微前端方案及qiankun介绍与开发实践

一、什么是微前端?

  我们先来看两个实际的场景:

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,很显然,父项目服务器上并没有这张图

  解决思路:

  1. 这里面的 js/css/img/video 等都是相对路径,能否通过 webpack 打包,将这些路径全部打包成绝对路径?这样就可以解决文件请求失败的问题。
  2. 能否手动(或借助 node )将子项目的文件全部拷贝到主项目服务器上,node 监听子项目文件有更新,就自动拷贝过来,并且按 js/css/img 文件夹合并
  3. 能否像 CDN 一样,一个服务器挂了,会去其他服务器上请求对应文件。或者说服务器之间的文件共享,主项目上的文件请求失败会自动去子服务器上找到并返回。

  通常做法是动态修改 webpack 打包的 publicPath,然后就可以自动注入前缀给这些资源。

  single-spa 是一个微前端框架,基本原理如上,在上述呈现子项目的基础上,还新增了 bootstrap 、 mount 、 unmount 等生命周期。

  相对于 iframesingle-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,作者:沉末_

posted @ 2022-03-11 17:00  菲比月  阅读(1226)  评论(0编辑  收藏  举报