基于 pnpm + monorepo 的 Qiankun微前端解决方案(内置模块联邦)

微前端搭建

主应用

安装 qiankun

javascript 复制代码
npm i qiankun -S

在主应用中注册微应用

当微应用信息注册完之后,一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配逻辑,所有 activeRule 规则匹配上的微应用就会被插入到指定的 container 中,同时依次调用微应用暴露出的生命周期钩子

javascript 复制代码
import { registerMicroApps, start } from 'qiankun'

// 每次加载切换子应用都会调用此方法
const loader = loading => {
  console.log('加载loading', loading)
}

// 1. 在基座中注册子应用
registerMicroApps(
  [
    {
      name: 'vue2-gojs', // 微应用的名称,微应用之间必须确保唯一
      entry: '//localhost:8001', // 微应用的入口
      activeRule: '/gojs', // 微应用的激活规则,当路径以 /react 为前缀时启动
      container: '#micro-container', // 微应用的容器节点的选择器或者 Element 实例
      loader, // loading 状态发生变化时会调用的方法
      props: { userInfo:{ name: 'burc', password: 'xxxxxx'} }, // 主应用需要传递给微应用的数据
    },
    {
      name: 'vue2-map-drilling',
      entry: '//localhost:8002',
      activeRule: '/map',
      container: '#micro-container',
      loader,
      props: {},
    },
    {
      name: 'vue3-pdf',
      entry: '//localhost:8003', 
      activeRule: '/pdf',
      container: '#micro-container',
      loader,
      props: {},
    },
  ],
)
start({})

子应用microApps

vue2+webpack

配置 webapck

配置 webpack,允许开发环境跨域和 umd 打包

javascript 复制代码
// vue.config.js

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

module.exports = defineConfig({
  transpileDependencies: true,
  lintOnSave: false,
  
  // 我们在 public-path.js 修改了 运行时publicPath,这里是可以省略的
  publicPath: process.env.NODE_ENV ? '/' : '/micro-apps/vue2-map-drilling/',
  
  devServer: {
    port: 8001,
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
  configureWebpack: {
    output: {
      libraryTarget: 'umd',
      library: `${packageName}`,
    },
  },
})
修改运行时 public

新增 public-path.js 文件,用于修改运行时的 publicPath,记得在 main.js 入口文件引入此文件

运行时publicPath是什么?参考 公共路径 | webpack为什么微应用加载的资源会 404?

javascript 复制代码
// public-path.js

if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
}
路由改造

修改基路径为子应用的路由激活规则,即 activeRule

javascript 复制代码
// router/index.js

import Vue from 'vue'
import VueRouter from 'vue-router'
import routes from './routes'

Vue.use(VueRouter)

export default () => {
  return new VueRouter({
    routes,
    mode: 'history',
    base: window.__POWERED_BY_QIANKUN__ ? '/map/' : '/', // 子应用的 activeRule
  })
}
入口文件改造

通过 window.POWERED_BY_QIANKUN 判断子应用是否在 qiankun 容器内运行

若在运行在 qiankun 容器中,则导出三个必要的生命周期函数,暴露接入协议

在 Qiankun 框架中,主应用负责加载和启动所有微前端子应用程序。

当子应用程序被加载时,Qiankun 框架会自动将 window.POWERED_BY_QIANKUN 变量注入到子应用程序的全局作用域中。这样,在子应用程序中就可以检测到当前应用程序是否在 Qiankun 容器内运行

javascript 复制代码
// main.js

import './public-path.js'
import Vue from 'vue'
import App from './App.vue'
import createRouter from '@/router'
import '@/assets/styles/reset.less'

Vue.config.productionTip = false

let app
let router
function render (props) {
  router = createRouter()

  app = new Vue({
    router,
    render: h => h(App),
  })

  const container = props.container
  app.$mount(container ? container.querySelector('#app') : '#app')
}

// 没有运行在乾坤容器中
if (!window.__POWERED_BY_QIANKUN__) {
  console.log('子应用独立启动')
  render({})
  const ele = document.querySelector('.micro-app')
  ele.style.height = '100vh'
}

/**
 * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
 * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
 */
export async function bootstrap () {
  console.log('react app bootstraped')
}

/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export async function mount (props) {
  render(props)
}

/**
 * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
 */
export async function unmount (props) {
  app.$destroy()
  app = null
  router = null
}

/**
 * 可选生命周期钩子,仅使用 loadMicroApp 方式加载微应用时生效
 */
export async function update (props) {
  console.log('update props', props)
}

vue3+vite

配置 vite

安装 vite-plugin-qiankun 并配置 pluginsvite-plugin-qiankun:帮助应用快速接入乾坤的vite插件

允许开发环境跨域和 umd 打包

设置 base,生产环境需要设置非根路径。没有类似 webpack 中的 运行时publicPath,也就是__webpack_public_path__,换句话说就是 vite 不支持运行时 publicPath,必须设置 base`

javascript 复制代码
// vite.config.ts

import qiankun from 'vite-plugin-qiankun'
import { name as packageName } from './package.json'

export default {
  // 这里的 'vue3-pdf' 是子应用名,主应用注册时AppName需保持一致
  plugins: [vue(), qiankun('vue3-pdf', { useDevMode: true })],
  
  // 公共基础路径,等同 webpack 的 publicPath
  base: process.env.NODE_ENV === 'development' ? '/' : '/micro-apps/vue3-pdf/',

  server: {
    port: 8003, 
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
  build: {
    target: 'esnext',
    lib: {
      name: `${packageName}-[name]`,
      formats: ['umd'],
    },
  },
}
路由改造

修改基路径为子应用的路由激活规则,即 activeRule

javascript 复制代码
import { createRouter, createWebHistory } from 'vue-router'
import { qiankunWindow } from 'vite-plugin-qiankun/dist/helper'
import routes from './routes'

export default () => {
  // 运行在 qiankun容器的话,添加前缀
  const base = qiankunWindow.__POWERED_BY_QIANKUN__ ? '/pdf/' : '/'
  return new createRouter({
    history: createWebHistory(base),
    routes
  })
}
入口文件改造

通过 qiankunWindow.__POWERED_BY_QIANKUN__判断子应用是否在 qiankun 容器内运行

若在运行在 qiankun 容器中,则导出三个必要的生命周期函数,暴露接入协议

qiankun官方是window.__POWERED_BY_QIANKUN__来检测到当前应用程序是否在 Qiankun 容器内运行,vite-plugin-qiankun 插件引用之后我们通过qiankunWindow.__POWERED_BY_QIANKUN__来判断

javascript 复制代码
import {
  renderWithQiankun,
  qiankunWindow,
} from 'vite-plugin-qiankun/dist/helper'

import { createApp } from 'vue'
import App from './App.vue'
import createRouter from './router'
import './assets/index.css'
import './assets/variable.less'

let instance = null

// 顶层作用域,如果写在 render 函数内部,重新挂载会报错
const router = createRouter()

function render(props) {
  instance = createApp(App)

  instance.use(router)

  const container = props.container
  instance.mount(container ? container.querySelector('#app') : '#app')
}

if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
  console.log('独立启动子应用')
  render({})
  const ele = document.querySelector('.micro-app')
  ele.style.height = '100vh'
} else {
  renderWithQiankun({
    mount(props) {
      console.log('mount')
      render(props)
    },
    bootstrap() {
      console.log('bootstrap')
    },
    unmount() {
      instance.unmount()
      instance = null
    },
  })
}

monorepo架构搭建

安装 pnpm

yaml 复制代码
npm i pnpm -g

初始化

根目录运行 pnpm init 创建 package.json 文件

然后根目录新建一个文件夹 packages,用于存储子包

新建 packages/libc-desgin( 公共组件包 ),即UI组件库,这里我们直接 clone 了 iview-ui-plus 代码。运行 pnpm install 安装依赖

修改 package.json 的 name 为 "@libc/desgin";修改 package.json 的 main 入口文件路径字段为 "src/index.js" 💥💥💥

shell 复制代码
// package.json

{
    "name": "@libc/desgin",
    "version": "1.3.19",
    "main": "src/index.js"
}

配置workspace

根目录新建一个 pnpm-workspace.yaml,将 【packages 下所有的目录】【主/子应用】【模块联邦】都作为包进行管理💥💥💥

此时,pnpm-workspace.yaml 工作空间下的每个子包都可以共享我们的公共依赖了

yaml 复制代码
packages:
  # packages 目录下的每一个目录都作为一个独立的模块
  - 'packages/*'
  # 主应用
  - 'main'
  # 微应用
  - 'microApps/*'
  # 模块联邦
  - 'public-app'

如果是老项目的话,需要将 npm 迁移到 pnpm,后面有迁移步骤!

依赖

全局依赖

全局安装公共依赖 lodash。需要加-w(在工作空间的根目录中启动 pnpm)

shell 复制代码
pnpm install lodash -w

这样,workspace 工作空间下所有的包 就都可以使用 lodash 库了

局部依赖

如果只有 vue3-pdf 项目用到了 lodash,我们也可以安装到vue3-pdf 子应用内部,不作为公共依赖项,有两种方法可以实现

  1. cd 到 microApps/vue3-pdf目录下,直接安装
shell 复制代码
pnpm install lodash
  1. 在任意目录下,使用 --filter 参数进行安装;package_selector:package.json 对应的 name 字段
shell 复制代码
pnpm install lodash --filter <package_selector>

共享子包

如何把子包 libc-desgin 共享出去?

子包之间可以通过 package.json 定义的 name 相互引用

包名是 package.json 中的 name,用--workspace参数去安装共享子包,会去 workspace工作空间中找依赖项并安装

shell 复制代码
pnpm install @libc/desgin --workspace -w

package.json 中就会自动添加如下依赖,"workspace:" 只会解析本地 workspace 包含的 package

shell 复制代码
 "dependencies": {
    "@libc/desgin": "workspace:^"
  }

工作空间下的项目都可以使用 @libc/desgin 里的组件,import 引入即可

shell 复制代码
import { isObject } from '@libc/desgin'

如何将 npm 迁移到 pnpm?

卸载之前的 node_modules

shell 复制代码
npm install rimraf -g

rimraf node_modules

创建 .npmrc

shell 复制代码
// .npmrc

# pnpm 配置
shamefully-hoist=false
auto-install-peers=true
strict-peer-dependencies=false

这里重点说下shamefully-hoist,默认 false。如果后续迁移失败可以考虑改为 true,删掉所有的 node_modules 重新 pnpm install 就行

  • false:node_modules下只能看到直接依赖的套件,次级依赖在node_modules/.pnpm 目录下;无法访问其他子包局部安装的依赖项,例如,vue-dome2 安装的 lodash,vue-dome1 是访问不到的
  • true:將所有套件都拉升到 node_modules 目錄下,能访问到其他子包局部安装的依赖项,例如,vue-dome2 安装的 lodash,vue-dome1 是能访问到的

转换 pnpm-lock.yaml

将 package-lock.json 和 yarn.lock 转成 pnpm-lock.yaml 文件,保证依赖版本不变

shell 复制代码
pnpm import

安装依赖包

shell 复制代码
pnpm install

最后,迁移完成!

在项目正常运行之后,可以删除原本的 package-lock.json 和 yarn.lock 文件,保持项目的整洁

模块联邦

一定要确保组件的提供方和消费方,是基于 webpack5 构建的!!!

组件提供方

javascript 复制代码
// vue.config.js

const { defineConfig } = require('@vue/cli-service')

// 自动化注入
const fs = require('fs')
const MFROOT = './src/components'
const MFList = fs.readdirSync(MFROOT)
const exposes = {}
MFList.forEach(item => {
  exposes[`./${item}`] = `${MFROOT}/${item}`
})

module.exports = defineConfig({
  transpileDependencies: true,

  devServer: {
    port: 9000,
    headers: {
      'Access-Control-Allow-Origin': '*'
    }
  },

  // 设置 publicPath,要不然模块联邦报错 ChunkLoadError: Loading chunk xxx failed
  publicPath: process.env.NODE_ENV === 'development' ? 'http://localhost:9000/' : '/public-app/',
  // publicPath: 'auto', // 设置 auto 也可以,自动推断在运行时的公共路径

  chainWebpack: config => {
    // 模块联邦 与 splitChunks冲突,关闭 splitChunks(代码分割)
    config.optimization.delete('splitChunks')

    // 模块联邦
    config
      .plugin('module-feaderation-plugin')
      .use(require('webpack').container.ModuleFederationPlugin, [
        {
          // 当前模块的名称
          name: 'provider',
          library: { type: 'umd', name: 'provider' },

          // 构建输出的文件名,组件消费方引用:http://localhost:9000/remoteEntry.js
          filename: 'remoteEntry.js',

          // 作为提供方,暴露的组件
          exposes

          // 作为提供方,暴露的组件
          // exposes: {
          //   './Page404': './src/components/Page404',
          //   './CustomModal': './src/components/CustomModal'
          // }
        }
      ])
  }
})

组件消费方

基于 URL 配置的 Remote

javascript 复制代码
// vue.config.js

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

module.exports = defineConfig({
  transpileDependencies: true,

  // qiankun配置,我们在 public-path.js 修改了 运行时 publicPath,这里是可以省略的
  // publicPath: process.env.NODE_ENV  === 'development' ? '/' : '/micro-apps/vue2-map-drilling/',

  devServer: {
    port: 8002,
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },

  configureWebpack: {
    output: {
      libraryTarget: 'umd',
      library: `${packageName}-[name]`,
    },
  },

  chainWebpack: config => {
    // 模块联邦
    config
      .plugin('module-feaderation-plugin')
      .use(require('webpack').container.ModuleFederationPlugin, [
        {
          // 当前模块的名称
          name: 'consumer',

          // 作为消费方,使用的时通过 ${remoteEntry}/${expose} 的方式使用, provider 是提供方 library 里的 name
          remotes: {
            remoteEntry: 'provider@http://localhost:9000/remoteEntry.js',
          },
        },
      ])
  },
})

基于 Promise 的动态 Remote

一般来说,remote 是使用 URL 配置的,但是你也可以向 remote 传递一个 promise,其会在运行时被调用

javascript 复制代码
// vue.config.js

module.exports = defineConfig({
  //...
  
  chainWebpack: config => {
    // 模块联邦
    config
      .plugin('module-feaderation-plugin')
      .use(require('webpack').container.ModuleFederationPlugin, [
        {
          // 当前模块的名称
          name: 'consumer',

          // 作为消费方,使用的时通过 ${remoteEntry}/${expose} 的方式使用, provider 是提供方 library 里的 name
          remotes: {
            remoteEntry: `promise new Promise(resolve => {
              const origin = window.location.origin
              // This part depends on how you plan on hosting and versioning your federated modules
              let remoteUrlWithVersion
              const NODE_ENV = origin.indexOf('localhost')>-1
              if(NODE_ENV){
                remoteUrlWithVersion = 'http://localhost:9000' + '/remoteEntry.js'
              }else{
                remoteUrlWithVersion = origin +'/public-app/remoteEntry.js'
              }
              const script = document.createElement('script')
              script.src = remoteUrlWithVersion
              script.onload = () => {
                // the injected script has loaded and is available on window
                // we can now resolve this Promise
                const proxy = {
                  get: (request) => window.provider.get(request),
                  init: (arg) => {
                    try {
                      return window.provider.init(arg)
                    } catch(e) {
                      console.log('remote container already initialized')
                    }
                  }
                }
                resolve(proxy)
              }
              // inject this script with the src set to the versioned remoteEntry.js
              document.head.appendChild(script);
            })
            `,
          },
        },
      ])
  },
})
相关推荐
招风的黑耳22 分钟前
Axure 高阶设计:打造“以假乱真”的多图片上传组件
javascript·图片上传·axure
拾光拾趣录26 分钟前
基础 | 🔥6种声明方式全解⚠️
前端·面试
flashlight_hi1 小时前
LeetCode 分类刷题:209. 长度最小的子数组
javascript·算法·leetcode
朱程2 小时前
AI 编程时代手工匠人代码打造 React 项目实战(四):使用路由参数 & mock 接口数据
前端
PineappleCoder2 小时前
深入浅出React状态提升:告别组件间的"鸡同鸭讲"!
前端·react.js
kfepiza2 小时前
Promise,then 与 async,await 相互转换 笔记250810
javascript
wycode2 小时前
Vue2源码笔记(1)编译时-模板代码如何生效之生成AST树
前端·vue.js
程序员嘉逸2 小时前
LESS 预处理器
前端
橡皮擦1992 小时前
PanJiaChen /vue-element-admin 多标签页TagsView方案总结
前端
程序员嘉逸2 小时前
SASS/SCSS 预处理器
前端