qiankun-框架搭建

一、介绍

1、概述

qiankun 是基于 single-spa 做了二次封装的微前端框架,通过解决了 single-spa 的一些弊端和不足,来帮助大家实现更简单、无痛的构建一个生产可用的微前端架构系统。

qiankun在子应用加载时,通过标识为entry或者加载的最后一个js文件为入口文件,子应用入口文件中需导出子应用生命周期(bootstrap、mount、unmount、update:可选)

2、主要优势

  1. 技术兼容性好,各个子应用可以基于不同的技术架构
  2. 代码库更小、内聚性更强
  3. 便于独立编译、测试和部署,可靠性更高
  4. 耦合性更低,各个团队可以独立开发,互不干扰
  5. 可维护性和扩展性更好,便于局部升级和增量升级

是否需要微前端架构可以参考博文:你可能并不需要微前端 - 知乎

3、缺点

  1. 子应用间的资源共享能力较差,使得项目总体积变大
  2. 开发人员要处理分布式系统的复杂性

4、题外话

为什么不用 iframe,这几乎是所有微前端方案第一个会被 challenge 的问题。但是大部分微前端方案又不约而同放弃了 iframe 方案,自然是有原因的,并不是为了 "炫技" 或者刻意追求 "特立独行"。

如果不考虑体验问题,iframe 几乎是最完美的微前端解决方案了。

iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。

  1. url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。

  2. UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
  3. 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
  4. 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。

其中有的问题比较好解决(问题1),有的问题我们可以睁一只眼闭一只眼(问题4),但有的问题我们则很难解决(问题3)甚至无法解决(问题2),而这些无法解决的问题恰恰又会给产品带来非常严重的体验问题, 最终导致我们舍弃了 iframe 方案。

二、问题

Q1:vite 的模块加载方式是 esm, 而 qiankun 并不支持在非 module script 标签内解析 esm 格式的代码,导致子应用无法正确加载

A1:可参考:vite-plugin-qiankun

Q2:样式隔离

A2:

import { start, loadMicroApp } from 'qiankun'
// 说明:结合加载子应用方式选择其中一种即可

// 方式1: 启动 qiankun
start({
  sandbox: {
    strictStyleIsolation: true,
  }
})

// 方式2: 手动加载子应用 reactApp
loadMicroApp('reactApp', {
  sandbox: {
    strictStyleIsolation: true,
  }
})

Q3:如何实现对 localStorage/cookie 等进行隔离,localStorage[key] 、response set-Cookie 等如何处理

A3: 沙箱里默认行为也是没有的,因为我们当时实践的场景是,隔离 localStorage/cookie 带来的麻烦可能比收益会更多。因为大部分时候恰好是希望共享 cookie 或者 localStorage 的,比如登录这种场景。如果确实需要,也可以自己在钩子里拿到沙箱实例,植入自定义行为。

其他常见问题及解决方案,请参考:https://www.cnblogs.com/goloving/p/14881461.html

三、框架搭建

 demo已上传GitHub,请参考:web-qiankun

主应用(mic-main vue3+vite+ts+element-plus+pinia)

// vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import VueSetupExtend from 'vite-plugin-vue-setup-extend'
import ElementPlus from 'unplugin-element-plus/vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
// https://vitejs.dev/config/

// 设置文根
// const BASE_URL = '/mic-pc-main/'
const BASE_URL = '/'

export default defineConfig({
  plugins: [
    vue(),
    VueSetupExtend(),
    ElementPlus(),
    AutoImport({
      imports: ['vue', 'vue-router', 'pinia'],
      resolvers: [ElementPlusResolver()]
    }),
    Components({
      resolvers: [ElementPlusResolver()]
    })
  ],
  css: {
    preprocessorOptions: {
      stylus: {
        imports: [resolve(__dirname, './src/assets/css/components/theme.styl')]
      }
    }
  },
  resolve: {
    // 设置快捷指向
    alias: {
      '@': resolve(__dirname, 'src')
    }
  },
  base: BASE_URL,
  server: {
    port: 8086,
    host: '0.0.0.0',
    https: false,
    open: false, // 启动服务是否自动打开浏览器
    cors: true, // 跨域
    // 代理
    proxy: {
      '/xxx': {
        target: 'http://xxx.xxx.xxx.xxx:xxxx',
        changeOrigin: true,
        secure: false
      }
    }
  }
})
// main.ts 重点:startMicros

import { createApp } from 'vue'
import App from './App.vue'
import router from './routers/index'
import { setupStore } from './stores/index'
import { getConf } from '@/conf/conf'
import { injectResponsiveStorage } from '@/utils/storage/responsive'
import { useI18n } from '@/i18n/i18n'
import '@/plugins/flexible'
import { directivesHook } from '@/directives/index'
import elementPlusFn from '@/plugins/element'
import { SHA1 } from '@/utils/utils'
import { PROPS_TYPE } from '@/micro/type'
import { startMicros } from '@/micro/index'

const app = createApp(App)
/**
 * 需要传递的一些方法
 */
const props: PROPS_TYPE = {
  SHA1: SHA1
}

// 获取初始化配置信息
getConf(app).then(async (config: any) => {
  app.use(router)
  await router.isReady()
  // 默认状态处理
  injectResponsiveStorage(config)
  // 状态管理
  setupStore(app)
  elementPlusFn(app)
  app.use(useI18n)
  // 指令
  directivesHook(app)
  // 启动微服务
  startMicros(props)
  app.mount('#app')
})
// micro/index.ts

import { registerMicroApps, addGlobalUncaughtErrorHandler, start } from 'qiankun'
import { getApps } from './apps'
import { PROPS_TYPE } from '@/micro/type'

/**
 * @description 添加全局异常捕获
 */
addGlobalUncaughtErrorHandler((event: any) => {
  console.error(event)
  const { message } = event
  if (message && message.includes('died in status LOADING_SOURCE_CODE')) {
    // message.error('微应用加载失败,请检查应用是否可运行')
    console.error('微应用加载失败,请检查应用是否可运行')
  } else {
    console.error(message)
  }
})

/**
 * 开始加载子程序
 * @param {*} props 默认需要传递的一些属性
 */
export function startMicros(props: PROPS_TYPE) {
  const apps = getApps(props)
  console.log('startMicros', apps)
  registerMicroApps(apps, {
    /**
     * @description 应用加载之前
     * @param {*} app
     */
    beforeLoad: (app: any) => {
      console.log('beforeLoad', app)
      // NProgress.start()
      return Promise.resolve()
    },
    /**
     * @description 微应用挂载之后
     * @param {*} app
     */
    afterMount: (app: any) => {
      console.log('afterMount', app)
      // NProgress.done()
      return Promise.resolve()
    },
    /**
     * @description 微应用卸载之后
     * @param {*} app
     */
    afterUnmount: (app: any) => {
      console.log('after unmount', app.name)
      return Promise.resolve()
    }
  })
  start({
    prefetch: 'all',
    sandbox: {
      experimentalStyleIsolation: true // 开启沙箱样式隔离
    }
  })
}
// app.ts

export const getApps = (props: AnyObject) => {
  return [
    {
      name: 'mic-pc-home',
      entry: process.env.NODE_ENV === 'production' ? window.location.origin + '/micPcHome/' : `//${window.location.hostname}:8081/`,
      container: '#frameSection',
      activeRule: '/mic-pc-home',
      props: Object.assign(props, { name: 'mic-pc-home' })
    },
    {
      name: 'mic-pc-dispatch',
      entry: process.env.NODE_ENV === 'production' ? window.location.origin + '/micPcDispatch/' : `//${window.location.hostname}:8082/`,
      container: '#frameSection',
      activeRule: '/mic-pc-dispatch',
      props: Object.assign(props, { name: 'mic-pc-dispatch' })
    }
  ]
}

子应用(mic-pc-home vue3+vite+ts)

子应用使用vite,需要通过vite-plugin-qiankun接入

// public-path.ts (src目录下添加)

// 判断是否是微前端环境
if ((window as any).__POWERED_BY_QIANKUN__) {
  // eslint-disable-next-line no-undef
  // @ts-ignore
  // 如果是qiankun子应用,则动态注入路径
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
}
// vite.config.ts

import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import VueSetupExtend from 'vite-plugin-vue-setup-extend'
import qiankun from 'vite-plugin-qiankun'
import ElementPlus from 'unplugin-element-plus/vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
// https://vitejs.dev/config/

// 设置文根 用于nginx上下文
const BASE_URL = '/mic-pc-home/'
// useDevMode 开启时与热更新插件冲突
// 如果是在主应用中加载子应用vite,必须打开这个,否则vite加载不成功, 单独运行没影响
const useDevMode = true

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd())
  const config = {
    plugins: [
      vue(),
      VueSetupExtend(),
      qiankun('mic-pc-home', { useDevMode }),
      ElementPlus(),
      AutoImport({
        imports: ['vue', 'vue-router', 'pinia'],
        resolvers: [ElementPlusResolver()]
      }),
      Components({
        resolvers: [ElementPlusResolver()]
      })
    ],
    css: {
      preprocessorOptions: {
        stylus: {
          imports: [resolve(__dirname, './src/assets/css/components/theme.styl')]
        }
      }
    },
    resolve: {
      // 设置快捷指向
      alias: {
        '@': resolve(__dirname, 'src')
      }
    },
    build: {
      commonjsOptions: {
        transformMixedEsModules: true
      }
    },
    define: {
      'process.env': env
    },
    base: BASE_URL,
    server: {
      port: 8081,
      host: '0.0.0.0',
      https: false,
      open: false, // 启动服务是否自动打开浏览器
      cors: true, // 跨域
      // 代理
      proxy: {
        '/xxx': {
          target: 'http://xxx.xxx.xxx.xxx:xxxx',
          changeOrigin: true,
          secure: false
        }
      }
    }
  }
  return config
})
// main.ts

import { createApp } from 'vue'
import App from './App.vue'
import router from './routers/index'
import { setupStore } from './stores/index'
import { useI18n } from '@/i18n/i18n'
import '@/plugins/flexible'
import { directivesHook } from '@/directives/index'
import elementPlusFn from '@/plugins/element'
// 主子应用对接
import { renderWithQiankun, qiankunWindow } from 'vite-plugin-qiankun/dist/helper'
import './public-path'
import { micInit, micUnmount } from './mic-api/index'

// vue实例
let instance = null
const renderObj = {
  // 状态管理器
  store: null,
  /**
   * @desc: vue实例函数
   * @param props 非必需,默认app
   * @return {*}
   **/
  async render(props: any) {
    const { container } = props
    console.log('props', props, container)
    instance = createApp(App)
    // 路由初始化
    instance.use(router)
    await router.isReady()
    // 状态管理
    setupStore(instance)
    elementPlusFn(instance)
    instance.use(useI18n)
    // 指令
    directivesHook(instance)
    instance.mount(container ? container.querySelector('#app') : '#app')
    // 判断是否是微前端环境
    if ((window as any).__POWERED_BY_QIANKUN__) {
      // 子应用初始化
      micInit(props)
    }
  },
  /**
   * @desc: 微应用事件接收处理
   * @param {*} props
   * @return {*}
   **/
  globalStateFn(props: any = {}) {
    props &&
      props.onGlobalStateChange &&
      props.onGlobalStateChange(
        /**
         * 接收主应用 setGlobalState
         * @param {*} value 传过来的值
         * @param {*} prev 改变以前的值
         */
        (value: any, prev: any) => {
          console.log('onGlobalStateChange', value, prev)
        },
        true
      )
  }
}
/**
 * bootstrap:bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
 *            通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
 * mount:应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 * unmount:应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
 */
renderWithQiankun({
  bootstrap: async () => {
    console.log('[vue] vue app bootstraped')
  },
  mount: (props: any) => {
    renderObj.render(props)
  },
  unmount: async () => {
    await micUnmount()
    renderObj.store = null
    // instance.$destroy()
    instance = null
  },
  update: () => {
    console.log('update')
  }
})

// 独立运行时,直接挂载应用
if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
  renderObj.render({})
}
// mic-api/index.ts

/**
 * @desc: 主子应用交互处理类
 **/
import { getCurrentInstance } from 'vue'

// 主应用传递实例
let micMain = null
let global: any = null

// 主数据初始化
export const micInit = (props: any) => {
  console.log('props', props)
  micMain = props
  global = getCurrentInstance()?.appContext.config.globalProperties
  global.$SHA1 = props.$SHA1
}

/**
 * @desc: 子应用注销调用
 **/
export const micUnmount = () => {
  console.log('子应用注销调用')
}

子应用(mic-pc-dispatch vue3+ts+webpack)

其余配置文件一致,主要体现在vue.config.js

// vue.config.js

const { defineConfig } = require('@vue/cli-service')
const path = require('path')
const packageName = require('./package.json').name
const UglifyJsPlugin = require('terser-webpack-plugin')

console.log('packageName', packageName)
// 生产环境根目录 -- 用于nginx上下文
const PROD_BASE_URL = '/mic-pc-dispatch/'
const outputDir = 'mic-pc-dispatch'
let port = '8082'
let host = 'localhost'
const publicPath = process.env.NODE_ENV === 'production' ? PROD_BASE_URL : `//${host}:${port}`

module.exports = defineConfig({
  // 根路径
  publicPath: publicPath,
  // 静态资源文件夹
  assetsDir: 'static',
  productionSourceMap: false,
  outputDir: outputDir,
  lintOnSave: true,
  transpileDependencies: true,
  // 打包方式设置为umd
  configureWebpack: config => {
    if (process.env.NODE_ENV === 'development') {
      config.devtool = 'source-map'
    }
    // 警告 webpack 的性能提示
    config.performance = {
      hints: 'warning',
      // 入口起点的最大体积 整数类型(以字节为单位)
      maxEntrypointSize: 50000000,
      // 生成文件的最大体积 整数类型(以字节为单位 300k)
      maxAssetSize: 30000000,
      // 只给出 js 文件的性能提示
      assetFilter: function (assetFilename) {
        return assetFilename.endsWith('.js')
      }
    }
    config.resolve.alias = Object.assign(config.resolve.alias, {
      '@': resolvePath('src')
    })
    // 自定义webpack配置
    config.output = Object.assign(config.output, {
      // 把子应用打包成 umd 库格式
      library: `${packageName}`,
      libraryTarget: 'umd',
      // jsonpFunction: `webpackJsonp_${packageName}`,
      globalObject: 'window'
    })
    if (process.env.NODE_ENV === 'production') {
      config.plugins.push(
        new UglifyJsPlugin({
          terserOptions: {
            warnings: false,
            compress: {
              drop_debugger: true, // console
              drop_console: true,
              pure_funcs: ['console.log'] // 移除console
            }
          },
          exclude: /\/node_modules/,
          sourceMap: false,
          parallel: true
        })
      )
    } else {
      // 为开发环境修改配置...
    }
  },
  // 本地服务器
  devServer: {
    host: '0.0.0.0', // IP
    port: port, // 端口号
    headers: {
      'Access-Control-Allow-Origin': '*'
    },
    // 代理 完整配置参考:https://github.com/chimurai/http-proxy-middleware#proxycontext-config
    proxy: {
      '/xxx': {
        target: 'http://xxx.xxx.xxx.xxx:xxxx', // 四川机关事务局-生产环境-互联网
        changeOrigin: true,
        secure: false
      }
    }
  },
  chainWebpack: config => {
    // 解决 cli3 热更新失效 https://github.com/vuejs/vue-cli/issues/1559
    config.resolve.symlinks(true)
    const types = ['vue-modules', 'vue', 'normal-modules', 'normal']
    types.forEach(type => addStyleResource(config.module.rule('stylus').oneOf(type)))
    config.module
      .rule('fonts')
      .use('url-loader')
      .loader('url-loader')
      .options({
        limit: 4096, // 小于4kb将会被打包成 base64
        fallback: {
          loader: 'file-loader',
          options: {
            name: 'fonts/[name].[hash:8].[ext]',
            publicPath: publicPath
          }
        }
      })
      .end()
    config.module
      .rule('images')
      .use('url-loader')
      .loader('url-loader')
      .options({
        limit: 4096, // 小于4kb将会被打包成 base64
        fallback: {
          loader: 'file-loader',
          options: {
            esModule: false,
            name: 'img/[name].[hash:8].[ext]',
            publicPath
          }
        }
      })
  }
})

function resolvePath (dir) {
  return path.join(__dirname, './', dir)
}
function addStyleResource (rule) {
  rule
    .use('style-resource')
    .loader('style-resources-loader')
    .options({
      patterns: [path.resolve(__dirname, './src/assets/css/imports.styl')]
    })
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值