基于 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);
            })
            `,
          },
        },
      ])
  },
})
相关推荐
Larcher18 小时前
新手也能学会,100行代码玩AI LOGO
前端·llm·html
徐子颐18 小时前
从 Vibe Coding 到 Agent Coding:Cursor 2.0 开启下一代 AI 开发范式
前端
小月鸭18 小时前
如何理解HTML语义化
前端·html
jump68019 小时前
url输入到网页展示会发生什么?
前端
诸葛韩信19 小时前
我们需要了解的Web Workers
前端
brzhang19 小时前
我觉得可以试试 TOON —— 一个为 LLM 而生的极致压缩数据格式
前端·后端·架构
yivifu19 小时前
JavaScript Selection API详解
java·前端·javascript
这儿有一堆花19 小时前
告别 Class 组件:拥抱 React Hooks 带来的函数式新范式
前端·javascript·react.js
熊猫钓鱼>_>19 小时前
Java面向对象核心面试技术考点深度解析
java·开发语言·面试·面向对象··class·oop
十二春秋19 小时前
场景模拟:基础路由配置
前端