微前端框架single-spa的搭建与应用

本文章会分几部分进行,主要搭建一个微前端应用,可以在企业级别的项目中使用的

微前端框架:single-spa、single-spa-vue

项目框架:vue2 + elementui

single-spa是一个很好的微前端基础框架,qiankun框架就是基于single-spa来实现的,在single-spa的基础上做了一层封装,也解决了single-spa的一些缺陷。
因为single-spa是一个基础的微前端框架,了解了它的实现原理,再去看其它的微前端框架,就会非常容易了。
本教程基于single-spa进行,本教程结束以后也会出一个基于qiankun的微前端实践。

demo地址demno(服务器不行,加载有点慢)

源码地址:single-spa-mainsingle-spa-child-onesingle-spa-child-two

源码会实时更新,如果和文章有出入的话,以源码为准(ui很丑,忍一忍)

本文简介:本demo会有一个主项目single-spa-main,两个子项目single-spa-child-one(spa1)single-spa-child-two(spa2),主项目内会包括两个子项目,并且子项目spa1内也引用的一个子模块spa2,对于spa1内引入的spa2只是一个思路,后续会出现的问题还不明确

废话结束,开始

第一步,我们先创建一个主项目

vue create single-spa-main
复制代码

创建完项目,我们要对项目进行一下改造

// vue.config.js
const StatsPlugin = require('stats-webpack-plugin')

module.exports = {
  publicPath:  process.env.VUE_APP_PUBLIC_PATH,
  outputDir: 'dist',
  assetsDir: 'static',
  runtimeCompiler: true,
  productionSourceMap: false,
  devServer: {
    port: 9000,
    hot: true,
    headers:{'Access-Control-Allow-Origin':'*'}
  },
  configureWebpack: config => ({
    output: {
      library: 'singleMain',
      libraryTarget: 'window'
    },
    plugins: [
      new StatsPlugin('manifest.json', {
        chunkModules: false,
        entrypoints: true,
        source: false,
        chunks: false,
        modules: false,
        assets: false,
        children: false,
        exclude: [/node_modules/]
      })
    ],
  }),
  chainWebpack: config => {},
  css: {
    extract: false,
    loaderOptions: {
      postcss: {
        plugins: [
          require('postcss-selector-namespace')({
            namespace (css) {
              if (css.includes("normalize.css")) return ''
              return '.single-spa-main'
            }
          })
        ]
      }
    }
  }
}

复制代码
librarylibraryTarget,把当前的项目挂到了window
stats-webpack-plugin的作用是将构建的统计信息写入文件manifest.json,对这个plugin有兴趣的同学可以仔细看一看这个api
主要说一下postcss-selector-namespace这个api,这个api的作用是将css的最前面加一个作用域的限制,因为微前端的原理是将代码插入到主项目的一个标签下,所以直接css要加一个作用域规定生效的父级,这样才不会把css搞乱,如果设置成功了,启动项目,主项目的css之前就会有刚才设置的前缀,做到css隔离的效果。

image.png

主项目暂时先改造到这里

第二步,我们创建一个子项目

vue create single-spa-child-one
复制代码

同样我们对这个项目进行一下改造,改造方式基本一样,注意一下名字就可以了

// vue.config.js
const StatsPlugin = require('stats-webpack-plugin')

module.exports = {
  publicPath: process.env.VUE_APP_PUBLIC_PATH,
  outputDir: 'dist',
  assetsDir: 'static',
  runtimeCompiler: true,
  productionSourceMap: false,
  devServer: {
    port: process.env.VUE_APP_PUBLIC_PORT,
    hot: true,
    headers:{'Access-Control-Allow-Origin':'*'}
  },
  configureWebpack: config => ({
    output: {
      library: 'singleChild1',
      libraryTarget: 'window'
    },
    plugins: [
      new StatsPlugin('manifest.json', {
        chunkModules: false,
        entrypoints: true,
        source: false,
        chunks: false,
        modules: false,
        assets: false,
        children: false,
        exclude: [/node_modules/]
      })
    ],
  }),
  chainWebpack: config => {},
  css: {
    extract: false,
    loaderOptions: {
      postcss: {
        plugins: [
          require('postcss-selector-namespace')({
            namespace () {
              return '#singleChild1'
            }
          })
        ]
      }
    }
  }
}
复制代码

按照一样的方法,创建一个single-spa-child-two

项目创建结束了,接下来我们继续改造项目

第一步,在主项目内进行子项目的注册

先创建一个singlespaMain.js,在main中引入进来,这个文件的作用就是注册子项目
// singlespaMain.js
import { registerApplication, start } from 'single-spa'
import axios from 'axios'
import eventRoot from './eventRoot'

// 远程加载子应用
function createScript(url) {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script')
    script.src = url
    script.onload = resolve
    script.onerror = reject
    const firstScript = document.getElementsByTagName('script')[0]
    firstScript.parentNode.insertBefore(script, firstScript)
  })
}

/*
* getManifest:远程加载manifest.json 文件,解析需要加载的js
* */
const getManifest = (url, bundle) => new Promise((resolve) => {
  axios.get(url).then(async res => {
    const { data } = res
    const { entrypoints, publicPath } = data
    const assets = entrypoints[bundle].assets
    for (let i = 0; i < assets.length; i++) {
      await createScript(publicPath + assets[i]).then(() => {
        if (i === assets.length - 1) {
          resolve()
        }
      })
    }
  })
})

// 子应用列表
const apps = [
  {
    // 子应用名称
    name: 'singleChild1',
    // 子应用加载函数,是一个promise
    app: async () => {
      let childModule = null
      await getManifest(`${process.env.VUE_APP_CHILD_ONE}/manifest.json?v=${new Date().getTime()}`, 'app').then(() => {
        childModule = window.singleChild1
      })
      return childModule
    },
    // 当路由满足条件时(返回true),激活(挂载)子应用
    activeWhen: location => {
      return location.pathname.startsWith('/singleChild1')
    },
    // 传递给子应用的对象
    customProps: { baseUrl: '/singleChild1', eventRoot }
  },
  {
    // 子应用名称
    name: 'singleChild2',
    // 子应用加载函数,是一个promise
    app: async () => {
      let childModule = null
      await getManifest(`${process.env.VUE_APP_CHILD_TWO}/manifest.json?v=${new Date().getTime()}`, 'app').then(() => {
        childModule = window.singleChild2
      })
      return childModule
    },
    // 当路由满足条件时(返回true),激活(挂载)子应用
    activeWhen: location => {
      return location.pathname.startsWith('/singleChild2')
    },
    // 传递给子应用的对象
    customProps: { baseUrl: '/singleChild2', eventRoot }
  }
]

apps.forEach(item => registerApplication(item))

start()
复制代码
getManifest函数配合createScript函数,会把子项目打包出来的js,加载出来
我们在主项目创建一个eventRoot,作为整个项目的事件通讯工具,内部实现就是new一个新的vue出来,通过customProps,逐级向下传递

需要注意的是传递的baseUrl这个字段,这个字段是用来规定子项目的基础路由,由于子项目可能在不同的地方使用,那这个页面的路由就会出现不同的情况,如果在子项目把路由写死的话,路由就会耦合,不利于多项目的应用,在spa1项目引用spa2的时候回着重说一下这个的应用

这个就是mnifest请求到的数据

manifest.json

接下来改造一下路由

// router.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import HelloWorld from '@/views/HelloWorld.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'HelloWorld',
    component: HelloWorld,
    children: [
      {
        path: 'singleChild1*',
        name: 'singleChild1',
        meta: {
          keepAlive: true
        }
      },
      {
        path: 'singleChild2*',
        name: 'singleChild2',
        meta: {
          keepAlive: true
        }
      }
    ]
  }
]

export default new VueRouter({
  mode: 'history',
  routes
})
复制代码
路由的改动基本都很小,在需要加载子项目的路由下加上children就可以了

接下来就是helloword这个组件

<template>
  <div class="hello">
    <el-tabs v-model="tabsname">
      <el-tab-pane label="用户管理" name="first">
        用户管理
        <div id="singleChild1"></div>
      </el-tab-pane>
      <el-tab-pane label="配置管理" name="second">
        配置管理
      </el-tab-pane>
      <el-tab-pane label="角色管理" name="third">
        角色管理
        <div id="singleChild2"></div>
      </el-tab-pane>
      <el-tab-pane label="定时任务补偿" name="fourth">定时任务补偿</el-tab-pane>
    </el-tabs>
  </div>
</template>
复制代码
这个页面只需要把id标签加到对应的位置就可以

主项目的改造到这里基本就可以告一段落了,先改造子项目,让这个应用跑起来

先从spa2下手,因为spa1里面也会引入spa2,所以spa1留到最后改造

// main.js
import Vue from 'vue'
import App from './App.vue'
import singleSpaVue from 'single-spa-vue'
import routerList from './router'
import registerRouter from './util/registerRouter'
 
Vue.config.productionTip = false
 
const appOptions = {
  el: '#singleChild2',
  render: h => h(App)
}
 
// 支持应用独立运行、部署,不依赖于基座应用
if (!window.singleSpaNavigate) {
  delete appOptions.el
  appOptions.router = registerRouter('', routerList)
  new Vue(appOptions).$mount('#app')
}
 
// 基于基座应用,导出生命周期函数
let vueLifecycle = ''

export function bootstrap ({ baseUrl, eventRoot }) {
  console.log('singleChild2 bootstrap', baseUrl)
  appOptions.router = registerRouter(baseUrl, routerList)
  Vue.prototype.eventRoot = eventRoot
  vueLifecycle = singleSpaVue({
    Vue,
    appOptions
  })
  return vueLifecycle.bootstrap(() => {})
}
 
export function mount () {
  console.log('singleChild2 mount')
  return vueLifecycle.mount(() => {})
}
 
export function unmount () {
  console.log('singleChild2 unmount')
  return vueLifecycle.unmount(() => {})
}
复制代码
// router.js
import HelloWorld from '@/views/HelloWorld.vue'

export default [
  {
    path: '/',
    name: 'HelloWorld',
    component: HelloWorld
  }
]
复制代码
// registerRouter.js
import Vue from 'vue'
import VueRouter from 'vue-router'

export default function (baseUrl = '', routerList) {
  Vue.use(VueRouter)
  return new VueRouter({
    base: baseUrl || '',
    mode: 'history',
    routes: routerList
  })
}
复制代码
启动两个项目,进入到对应的tab下,页面就能加载出来了

image.png

这里直接把跨项目事件加进去

// spa2
// HelloWorld.vue
<template>
  <div class="hello">
    <h1>spa2</h1>
    <button @click="qiehuan">xiu~~切换</button>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  methods: {
    qiehuan() {
      this.eventRoot.$emit('goFourth')
    }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped></style>
复制代码

点击的时候会向上emit一个事件,我们在其他项目内对这个事件进行一下监听就可以了

// spa-main
// HelloWorld.vue
<template>
  <div class="hello">
    <el-tabs v-model="tabsname" :before-leave="qiehuan">
      <el-tab-pane label="用户管理" name="first">
        用户管理
        <div id="singleChild1"></div>
      </el-tab-pane>
      <el-tab-pane label="配置管理" name="second">
        配置管理
      </el-tab-pane>
      <el-tab-pane label="角色管理" name="third">
        角色管理
        <div id="singleChild2"></div>
      </el-tab-pane>
      <el-tab-pane label="定时任务补偿" name="fourth">定时任务补偿</el-tab-pane>
    </el-tabs>
  </div>
</template>

<script>
import eventRoot from '@/eventRoot'

export default {
  name: 'HelloWorld',
  data () {
    return {
      tabsname: 'second'
    }
  },
  created () {
    eventRoot.$on('goFourth', this.goFourth)
  },
  methods: {
    qiehuan (changeName) {
      if (changeName === 'first') {
        this.$router.push({
          path: '/singleChild1'
        })
      } else if (changeName === 'third') {
        this.$router.push({
          path: '/singleChild2'
        })
      } else if (this.tabsname === 'first' || this.tabsname === 'third') {
        this.$router.push({
          path: '/'
        })
      }
    },
    goFourth () {
      this.qiehuan('fourth')
      this.tabsname = 'fourth'
    }
  }
}
</script>

<style scoped>
.hello {
  text-align: center;
}
</style>

复制代码

下面我们来进行spa1项目的改造,因为在我们的计划当中spa1既是一个子项目,同时也要把spa2当做一个子模块引入进来,所以spa1既是一个子项目,也是一个主项目

// spa1
// main.js
import Vue from 'vue'
import App from './App.vue'
import singleSpaVue from 'single-spa-vue'
import routerList from './router'
import registerRouter from './util/registerRouter'

import singlespaSpa1 from './singlespaSpa1'
import eventRoot from './eventRoot'
 
Vue.config.productionTip = false
 
const appOptions = {
  el: '#singleChild1',
  render: h => h(App)
}

// 支持应用独立运行、部署,不依赖于基座应用
if (!window.singleMain) {
  delete appOptions.el
  appOptions.router = registerRouter('', routerList)
  singlespaSpa1('', eventRoot)
  Vue.prototype.eventRoot = eventRoot
  new Vue(appOptions).$mount('#app')
}
 
// 基于基座应用,导出生命周期函数
let vueLifecycle = ''

export function bootstrap ({ baseUrl, eventRoot }) {
  console.log('singleChild1 bootstrap', baseUrl)
  appOptions.router = registerRouter(baseUrl, routerList)
  singlespaSpa1(baseUrl, eventRoot)
  Vue.prototype.eventRoot = eventRoot
  vueLifecycle = singleSpaVue({
    Vue,
    appOptions
  })
  return vueLifecycle.bootstrap(() => {})
}
 
export function mount () {
  console.log('singleChild1 mount')
  return vueLifecycle.mount(() => {})
}
 
export function unmount () {
  console.log('singleChild1 unmount')
  return vueLifecycle.unmount(() => {})
}
复制代码
// singlespaSpa1
import { registerApplication, start } from 'single-spa'
import axios from 'axios'

// 远程加载子应用
function createScript(url) {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script')
    script.src = url
    script.onload = resolve
    script.onerror = reject
    const firstScript = document.getElementsByTagName('script')[0]
    firstScript.parentNode.insertBefore(script, firstScript)
  })
}

/*
* getManifest:远程加载manifest.json 文件,解析需要加载的js
* */
const getManifest = (url, bundle) => new Promise((resolve) => {
  axios.get(url).then(async res => {
    const { data } = res
    const { entrypoints, publicPath } = data
    const assets = entrypoints[bundle].assets
    for (let i = 0; i < assets.length; i++) {
      await createScript(publicPath + assets[i]).then(() => {
        if (i === assets.length - 1) {
          resolve()
        }
      })
    }
  })
})

// 子应用列表
const apps = function (baseUrl, eventRoot) {
  return [
    {
      // 子应用名称
      name: 'singleChild2',
      // 子应用加载函数,是一个promise
      app: async () => {
        let childModule = null
        await getManifest(`${process.env.VUE_APP_CHILD_TWO}/manifest.json?v=${new Date().getTime()}`, 'app').then(() => {
          childModule = window.singleChild2
        })
        return childModule
      },
      // 当路由满足条件时(返回true),激活(挂载)子应用
      activeWhen: location => {
        return location.pathname.startsWith(`${baseUrl}/spa1spa2`)
      },
      // 传递给子应用的对象
      customProps: { baseUrl: `${baseUrl}/spa1spa2`, eventRoot }
    }
  ]
}

export default function (baseUrl, eventRoot) {
  apps(baseUrl, eventRoot).forEach(item => registerApplication(item))
  start()
}
复制代码
// router.js
import Vue from 'vue'
import HelloWorld from '@/views/HelloWorld.vue'
import Test1 from '@/views/test1.vue'

export default [
  {
    path: '/',
    name: 'HelloWorld',
    component: HelloWorld,
    children: [
      {
        path: 'spa1spa2',
        name: 'singleChild2',
        component: Vue.component('singleChild2', {
          render: h => h('div', { attrs: { id: 'singleChild2' } })
        }),
        meta: {
          keepAlive: true
        }
      }
    ]
  },
  {
    path: '/test1',
    name: 'test1',
    component: Test1
  }
]
复制代码
<!-- HelloWorld.vue -->
<template>
  <div class="hello">
    <h1>spa1</h1>
    <button @click="showSpa2">showSpa2</button>
    <router-view />
    <button @click="go">gotest</button>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  methods: {
    showSpa2 () {
      this.$router.push({
        name: 'singleChild2'
      })
    },
    go () {
      this.$router.push({
         name: 'test1'
      })
    }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
  .hello {
    font-size: 30px;
  }
</style>
复制代码
同样的地方就不多说了,不同地方:
1、主要就是singlespaSpa1这个加载子模块的文件输出变成了一个函数,因为子模块的加载依赖一个baseUrl,子项目的baseurl又是上一个项目传递过来的,还有全局的eventRoot需要传递,所以这里就变成了一个函数的形式
2、这里判断是不是独立运行的需要用主项目挂载window上library判断,也就是window.singleMain,因为我们这个项目也引入了single-spa,所以window.singleSpaNavigate一定会存在
3、路由为什么没有用主项目的*这种形式,不建议用这个形式,会在页面上多出很多判断,router-view是最好的
4、这里还需要创建一个eventRoot,用于在不依赖主项目的情况下,实现与子模块的通信
如果启动没问题,页面应该是这样的

image.png

点击showSpa2以后

image.png

文章到此就已经结束了,后面还会在出一个关于优化的文章,关于以上只是一个最基本的代码配置,如果项目复杂的话,根据自己的需求在改造

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享