前端
vue项目的微前端实践

项目背景

开发体验问题

后台管理系统(以下简称ams)项目的路由达700个,本地启动项目去开发时,往往需要至少5分钟+的等待时间(大部分时间是在编译项目组成员书写的vue代码)

技术更换成本问题

ams至少算中大型项目,开发人员多达10+人。代码量大。如此庞大的项目,如果后期需要技术更换or更新,成本和风险都较大。(目前项目架构为【vue-cli构建的单页应用】,涉及全局的依赖如vue-router,vuex要么不换,要么全站替换)

微前端-当下分治思想下的解决方案

延伸自后端-微服务的架构,前端也有对应的思路: 每个子项目都是独立的,各自负责各自任务,组合起来是一整个应用。

主要有两个思路
  • 结合ams项目的特点(主站分topbar,sidebar,mainContent。mainContent下各模块具有清晰边界,且模块间通讯不多),主要有两个思路
  1. iframe集成方式:mainContent加载对应路由页面
  • 优点: 简单粗暴,对项目代码改动不大(去除iframe内的top,sidebar渲染)
  • 缺点: 通讯成本高;element-dialog类型的弹窗只能在iframe内展示。(iframe自带天然隔离的短板)
  1. single-spa集成:不同dom对应不同挂载起点,即同一个html外壳
  • 优点: 技术栈无关,各个子项目可以自由选择框架;可独立部署,互不影响。
  • 缺点: 多出子项目的部署成本;子项目共享document,window等全局变量容易冲突;

相较之下

  1. single-spa的优点与我们ams目前的特点更契合
  2. 业界single-spa有成熟的框架:乾坤(qiankun)
  3. 了解完乾坤,笔者更加确定single-spa是ams系统下一个演变的架构。
qiankun完美符合ams项目的前端架构的拆分
  1. 基座模式
  • ams站点主要三大块: sidebar,topbar,mainContent。而sidebar,topbar的组合正好可以形成qiankun的基座模式: 一个业务无关的主项目入口自然形成
  1. 拆分成本低,且简单
  • 子项目所有路由都可以从原项目原原本本迁移,业务代码不做任何改动。
  1. html类型的entry
  • 子项目依旧和原项目的构建方式一样。是【只有某几个路由】的vue项目,打包出html+js+css(主应用加载时,qiankun利用fetch子html然后正则匹配出子项目所需的js&css)

实施

  • 因ams项目影响较大,为了避免与业务正常迭代的冲突,选择目前前端结构接近的内容管理系统(以下统称cms)项目以及其测试环境实施
主项目(ops-main)改造关键步骤
// 1. main.js--注册子项目
import { registerMicroApps } from 'qiankun';
const actions = subStoreInit(store)
registerMicroApps([
    {
        name: 'ops-sub-content',
        entry: process.env.NODE_ENV == 'development' ? '//localhost:9091/sub1/' : '/sub1/', // 表明子项目在开发时端口为9091,线上则取/sub1/
        container: '#mainFrame',// 表明这个子项目将被挂载在此dom
        activeRule: ['/content', '/sms', '/funding', '/system'], // 主项目判断:如命中此些路由则可以渲染子项目,这里为笔者做过的4个模块
        props: {
            actions: actions, // 主项目vuex.store通过props传到子项目
        }
    }
])
// 2. Home.vue--启动子项目
<div class="content" id="mainFrame1" :style="mainAppStyle">
    <transition name="move" mode="out-in">
      <keep-alive>
        <router-view :key="$route.fullPath"></router-view>
      </keep-alive>
    </transition>
</div>
<div class="content" id="mainFrame"></div>
import { start } from 'qiankun';
computed: {
    mainAppStyle() {
    // 由于ops有大部分路由仍在主项目,估保留此dom用于展示非子项目的路由页面,命中子项目时隐藏之并展示mainFrame
      const subArr = ['/content', '/sms', '/funding', '/system']
      return {
        display: subArr.some(i=>this.$route.fullPath.startsWith(i))  ? 'none' : 'block'
      }
    }
  },
mounted() {
    /* 
      选择此处启动是为了保证主项目一定存在挂在子项目的dom节点
    */
    start();
}


// 3. router/sms.js 保留子项目路由,去除对应组件
    { meta: { title: '短信模板' }, path: '/sms/template', name: '/sms/template', component: template }
    ->
    { meta: { title: '短信模板' }, path: '/sms/template', name: '/sms/template' },
// hack,原有站点的sidebar,topBar的el-menu组件都关联了路由(左边点开推入路由历史list,顶部渲染路由历史list)
// 所以这里笔者仍保留子项目路由,只去除对应组件


// 4. src/store.js 注册子项目将要调用的事件
import { initGlobalState } from 'qiankun'
export default function subStoreInit(store) {
  const {state: initialState} = store
  const actions = initGlobalState(initialState)
  // 主项目原本在topBar有删除标签页的vuex操作
  actions.onGlobalStateChange((newState, prev) => {
    if (!!newState.delTag) { // 子项目触发,通知主项目删除标签页
      store.commit("delTag", newState.delTag);
    }
  })
  // 定义一个获取state的方法下发到子应用
  actions.getGlobalState = (key) => {
    // 有key,表示取globalState下的某个子级对象
    // 无key,表示取全部
    return key ? initialState[key] : initialState
  }
  return actions
}
子项目(ops-sub)改造关键步骤
// 1. src/main.js 导出qiankun生命周期为主项目所用
if (window.__POWERED_BY_QIANKUN__) {
    // 作为子项目启动时的环境判断:有无运行父项目
    __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
}
// import routes from './router' 不再直接使用路由
import VueRouter from 'vue-router';
import globalRegister from './global-register' // 接收主项目下发的store
import CMS from './router/cms'; //路由 内容管理
import sms from './router/sms'; //路由 短信
import { mapState } from "vuex";
// import 'babel-polyfill' // 主应用已经有polyfill了
Vue.use(VueRouter)
Vue.mixin({
    computed: {
        ...mapState('global', {
            auths: state => state.base.auths, // 获取主项目下发的state: 权限列表
        }),
        auth() {
            // const { auths } = this.$store.state.base 不再取自身state
            const { path } = this.$route
            return getCurrentPathAuth(path, this.auths)
            // 配合子项目的路由和主项目的权限列表获取具体权限
        }
    },
})
// new Vue({
//     router,
//     store,
//     render: h => h(App)
// }).$mount('#app') 不再直接渲染#app
let instance = null
function render (props = {}) {
  const { container } = props
  const router = new VueRouter({
    // 表明独立运行的时候会带后缀/sub1, 在主项目里则不带
    base: window.__POWERED_BY_QIANKUN__ ? '/' : '/sub1/',
    mode: 'history',
    routes: [
      ...CMS,
      ...sms, // 动态注册路由
    ]
  })
  // 子项目渲染节点要么来自index.html>body>div#ops-sub-content,要么来自主项目下的div#ops-sub-content
  instance = new Vue({
    router,
    store,
    render: (h) => h(App)
  }).$mount(container ? container.querySelector('#ops-sub-content') : '#ops-sub-content')
}
if (!window.__POWERED_BY_QIANKUN__) {
  render() // 独立运行
}
// 主项目需要知道的子项目生命周期1
export async function bootstrap () {
  console.log('[vue] vue app bootstraped')
}
// 主项目需要知道的子项目生命周期2
export async function mount (props) {
    // 巧妙利用vuex实例方法:registerModule动态注册由主项目传来的store
    globalRegister(store, props.actions)
    // 渲染子项目
    render(props)
}
// 给 body 、 document 等绑定的事件,请在 unmount 周期清除
export async function unmount () {
  instance.$destroy()
  instance.$el.innerHTML = ''
  instance = null
  // 卸载vue实例
}

2

// 2. index.html换id
<div id="app"></div> 
<script type="text/javascript" src="https://api.map.baidu.com/api?v=2.0&ak=ucSrcUGg5N4Gr4vAyThxjuVLqbYFKfDW&s=1"></script>
->
<div id="ops-sub-content"></div>
<!-- 2. 避免作为子项目与其他vue项目起冲突 -->
<!-- <script type="text/javascript" src="https://api.map.baidu.com/api?v=2.0&ak=ucSrcUGg5N4Gr4vAyThxjuVLqbYFKfDW&s=1"></script> -->
<!--前文提到qiankun会动态发出fetch请求获取index.html里的js,css,这里的百度地图不允许跨域会导致子应用无法加载在主应用上,所以子应用还需要特殊获取一下百度地图sdkjs-->

3

// 3. global-register.js 接收主项目store
/**
 * 
 * @param {vuex实例} store 
 * @param {qiankun下发的props} props 
 */
function registerGlobalModule (store, props = {}) {
  if (!store || !store.hasModule) {
    return;
  }
  // 获取初始化的state
  const initState = props.getGlobalState && props.getGlobalState() || {
  };
  // 将父应用的数据存储到子应用中,命名空间固定为global
  if (!store.hasModule('global')) {
    const globalModule = {
      namespaced: true,
      state: initState,
      actions: {
        // 子应用改变state并通知父应用
        setGlobalState ({ commit }, payload) {
          commit('setGlobalState', payload);
          commit('emitGlobalState', payload);
        },
        // 初始化,只用于mount时同步父应用的数据
        initGlobalState ({ commit }, payload) {
          commit('setGlobalState', payload);
        },
      },
      mutations: {
        setGlobalState (state, payload) {
          // eslint-disable-next-line
          state = Object.assign(state, payload);
        },
        // 通知父应用
        emitGlobalState (state, payload) {
          if (props.setGlobalState) {
            props.setGlobalState(payload);
          }
        },
        /* 与主交互 */
        delTag ({ commit }, payload) {
          if (props.setGlobalState) {
            props.setGlobalState({delTag: payload});
          }
        },
      },
    };
    store.registerModule('global', globalModule);
  } else {
    // 每次mount时,都同步一次父应用数据
    store.dispatch('global/initGlobalState', initState);
  }
};
export default registerGlobalModule;

1

4. vue.config.js 构建变化
const { name } = require('./package.json')
module.exports = {
    baseUrl: '/sub1/', // 打包时为了区别于主项目的路径(既文件位置)
    devServer: {
        port: 9091, // 9091端口
        headers: {
          'Access-Control-Allow-Origin': '*' // 主应用获取子应用时跨域响应头
        }
    },
    configureWebpack: {
        output: {
          library: `${name}-[name]`,
          libraryTarget: 'umd',
          jsonpFunction: `webpackJsonp_${name}`,
        }
    },
}
5. 任意组件想关闭主应用tag时
      this.$store.commit("delTag", this.$router.history.current.fullPath);
    ->
     this.$store.commit("global/delTag", this.$router.history.current.fullPath);
nginx反代改动
server {
    listen       8888;
    server_name localhost;
    location / {
        root   E:/gjx-work/ops-main/dist;  # 主应用所在的目录
        index index.html;
        try_files $uri $uri/ /index.html; # 转发
    }
    location /sub1 {
        alias E:/gjx-work/ops-sub/dist; # 子应用遇到sub1则替换路径
        try_files $uri $uri/ /index.html; # 转发
    }
}


由于这是一个拆分项目的过渡时期,cms主项目是保留cms原项目不变
cms子项目appkey为: cms.web.portal.sub1,打包流程与主项目不变

自此,基于qiankun的微前端方案我们算初步改造完成了,部分效果图:

  • 子项目部署期间403

  • 子项目独立开发

优化

  • 以上的代码改动还不算最优解,经过同事间的讨论,还有一些值得优化的地方:

主项目路由问题

  • 主子项目都需要对同一份路由注册(由于项目的sidebar用的element-nav组件,且菜单是经过鉴权后的菜单,根据主项目的分工,主项目也得注册一份路由来进行跳转)
  • 领导过目后有个很惊人的想法: 主项目路由能不能动态的注册子项目路由呢?(这样做的收益点在于:分管项目其他部分的小伙伴不再需要关注主项目有没有注册)
  • 当然是可以的,我们想到了基于路由守卫beforeEach的方案,思路以及运行流程如下:

  • 进入子路由时:
  1. 守卫beforeEach中:命中子项目路由(如/setting/log),跳转中介路由/sub
  2. 中介组件mounted里实现子项目挂载(qiankun发出fetch请求获取子项目css,js), 监听新增路由的派发(onEmit: addRouter)
  3. 子项目render,派发路由列表(emit: addRouter),监听权限变化的派发(onEmit: authChange,实现页面按钮的鉴权)
  4. 收到emit派发,利用vue-router新api: router.addRouters实现动态添加路由
  5. 跳转命中的路由(/setting/log)
  6. nhome组件算出当前路由auth,结合vuex的subscribe和window.dispatchEvent派发至子项目(emit: authChange)
  7. 子项目执行store.commit,设置当前页面的按钮权限
  • 说明:
    store的状态管理要有明确的界限:主项目负责token,页面权限,右上角分公司等全局变量;子通过window.addEventListener简介接收主项目store
    依赖更新:主项目需要更新vue-router, api:addRouters的在v3.5.1版本有;子项目更新vuex, api:registerModule在v3.6.2有

子项目环境问题

  • 领导继续提出,子项目开发时应该可以判断环境,若单独开发则可以模拟的展示侧边的【本地】菜单
  • 实现比较简单:直接利用window.POWERED_BY_QIANKUN 判断是否是独立开发,再把子项目路由声明的遍历一遍去除name和path生成sidebar既可
<template>
    ...    
    <SideBar v-if="subOnly"/>    
    <div :class="subOnly ? 'subOnly' : ''">        
        <router-view></router-view>    
    </div>    
    ...
</template>

子项目相遇问题

  • 想象一下,如果我是负责子项目A的,恰好需要跳转到子项目B,这在开发时该如何是好? 运行子项目B?
  • 不,领导又一次提出惊艳的想法: 本地开发时,能不能在主项目判断有无运行子项目,有则加载运行中的子项目,无则加载测试环境的子项目?
  • 这个问题超纲了,最后我是间接实现的,而且还不能提交代码(果然菜是原罪😂):
    修改主项目代码loadMicroApp.entry改为: https://ams-beta.domain.com/sub-b项目

问题汇总

store同步问题
  • 主子应该有明确的界限如:
  1. 主负责全局数据【页面tag】
  2. 子负责局部数据如【只在此子项目使用的全局state:cityList】
  • 有父传子的场景:auth子不能再用store.state读取:子项目的state获取由之前的‘this.$store.state.xxx’变为带global命名空间的computed属性如:...mapState('global', {auths: state => state.xxx}),子项目的commit:key交互由之前的‘xxx’变为‘global/xxx’
  • 有子传父的场景: delete 【页面tag】
  1. 不能再用$store.commit('delTag')
  2. this.$store.commit("global/delTag", this.$router.history.current.fullPath);
  • 鲜少遇到两个子通讯的问题,但有互相跳转的场景使用history.pushState()
  • 子项目如若有与主项目共享的localStorage(如token)/sesstionStorage等全局变量:在不开主项目的情况下,仍需额外单独地设置localStorage/sesstionStorage
popState监听问题
  • single-spa重写了popState的监听,并在子应用销毁的时候用dispatchEvent的浏览器Api主动触发了popState事件,这给主应用对popState事件的监听造成麻烦
  • single-spa重写了history.pushState 并会在每次任何地方的push之后主动dispatchEvent
  • 解决: 幸好single-spa重写的popState事件有个singleSpa的属性,可以在主项目原有的popState逻辑里添加:
window.addEventListener('popstate', (e)=>{    
if (!e.singleSpa) {        
// 业务逻辑,表示不是single-spa触发的    
}}, false)
刷新回到welcome页问题
  • home组件的created里实现了【刷新只能回到最后一个路由】,而刷新子项目的路由又是先跳转到/sub再到子路由,所以只能在created里实现/sub的白名单
子项目的子路由问题
  • 子项目子路由虽说已经注册进来,但原有的closeTag实现仍有问题: 关闭标签的实现在于tag组件watch的store:currentTags回调里实现tag的减少,但tag组件里也有$route的监听,里头对storel:currentTags有可能新增/替换。这样就成了冲突
  • 解决: $route里头,若tagList无变化,不需要新增store里的tag
面包屑问题
  • 面包屑获取子路由的方式为: $router.option.route。这样获取不到动态添加的route
  • 解决: $router.getRouters()
跨域问题
  • 子项目sub_xxx 无跨域返回头,导致本地访问不了beta上的子项目
  • 运维表示已经加返回头了,但仍有【content-type: text/html】类型的返回无此头
  • 解决: 直到运维配置好带*的返回
vue2问题
  • 主项目仍存在window.vue的引用,如何在子项目加载后(也就是vue丢失)还正常使用?(官网的解决方案不行Vue Router 报错 Uncaught TypeError: Cannot redefine property: $router)
主项目路由popState问题
  • 之前的history.back()是先触发goBack再触发vue-router
  • 假如主接入qiankun,q利用single-spa会全权代理我们goback和vuerouter的监听,此时执行顺序已经变了:先vue-router,后goBack
  • 原因是single-spa重写之后我们对popstate再监听他会执行,但不会等promise比如watch先执行。
多个应用都使用loadMicroApp后的切换问题
  1. qiankun对此api的设计是完全由用户自主挂载,销毁的。
  2. 两个子应用先后对同一个dom做挂载后,想靠history.pushState来切换是不行的,页面只会保留最后loadMicroApp执行后的应用。
  3. 解决方向支一:此api不符合菜单来回切换的需求,改为注册方式registerMicroApps。(整个加载流程都推翻了,难度在全局404的跳转时机)
  4. 解决方向之二: 不对同一个dom做挂载。(简单,但后期存在多个子项目dom过多问题)

几个值得探讨的问题

  1. 子项目应该拆成哪几部分?依据是什么
  2. 若存在不同子项目,子项目之间该如何通讯?该如何跳转
  3. 主子项目都有共同的依赖&工具代码util,可以如何复用?

小结:

前端的发展很快,项目架构一直在演变,我们要学会利用先进的思想去解决当下的实际问题。当下qiankun的微前端解决方案是适合ams类型的项目的。但未来又会出现哪些改变,我们不得而知。庆幸的是有一群布道者带领我们前进,此次项目改造参考了qiankun官网,github issue,个人博客等平台,经过了多次踩坑,落地不易。这里笔者希望和各位一起学习,共同进步,做到与时俱进。



日期:2021-12-29 10:52 | 阅读:298 | 评论:0