新增一个 demo 子应用 - 微前端

动手实验 ------ 新增一个 demo 子应用

目标:在一个「已有主应用 + 若干子应用」的 monorepo 里,新增一个名为 demo 的子应用,让它能通过 /demo-app 路由被主应用加载。 假设你的仓库结构类似:apps/main(主应用)、apps/xxx(各子应用)。我们要新增 apps/demo

步骤总览

  1. 新建 apps/demo 目录与基础文件
  2. vue.config.js(打包成 UMD + 允许跨域)
  3. public-path.js(修正静态资源路径)
  4. main.js(导出生命周期 bootstrap/mount/unmount)
  5. 在主应用 micro-app.js 注册这个子应用
  6. 在环境配置里加 entry / activeRule
  7. 启动验证

步骤 1:新建 apps/demo 目录与基础文件

方式 A(最快 · 推荐先用):复制现有子应用改名

已有子应用(如 apps/app1)的结构和微前端接入代码是现成的、已验证过的。直接复制一份再改差异点最省心:

bash 复制代码
cp -r apps/app1 apps/demo          # 复制一个结构最接近的子应用
# 然后改这几处即可:
#  1. apps/demo/package.json 的 name → "demo"
#  2. vue.config.js 的 devServer.port → 唯一端口(如 9101)
#  3. src/public-path.js 一般无需改(逻辑通用)
#  4. src/main.js 里路由 base、挂载点 id 改成 demo 自己的
#  5. public/index.html 挂载点 id 改成 #demo-app
#  6. 删掉 app1 的业务页面/路由,换成你自己的

好处:UMD/跨域/生命周期这些「命门配置」直接继承,不易漏;坏处:会带进被复制应用的业务残留,需要清理干净。

方式 B(可复用 · 团队推荐):用 Vue CLI 官方脚手架起壳,再补微前端 4 处改造

bash 复制代码
npx @vue/cli create apps/demo --preset ...   # 或交互式选 Vue2 + Router

生成标准 Vue 工程后,只需补微前端接入的 4 件事:加 public-path.js、改 main.js 导出生命周期、vue.config.js 配 UMD+跨域、独有挂载点 id。下面步骤 2~4 讲的正是这 4 件事。

方式 C(一劳永逸 · 进阶):写一个 plop 生成器

如果以后要频繁新增子应用,值得在仓库里加 plop:把本文档步骤 1~4 的文件做成模板,交互式输入「应用名 / 端口 / activeRule」即可一键生成整套文件,并自动避免端口和 library 命名冲突。这是把本教程「产品化」的做法,示例见文末「进阶练习 5」。


创建如下结构:

arduino 复制代码
apps/demo/
├── package.json
├── vue.config.js
├── public/
│   └── index.html
└── src/
    ├── public-path.js
    ├── main.js
    ├── App.vue
    └── router.js

1.1 apps/demo/package.json

json 复制代码
{
  "name": "demo",          // 包名。monorepo 里的唯一标识,pnpm --filter demo 就靠它定位
  "version": "1.0.0",      // 版本号(语义化版本 主.次.补丁)。私有子应用其实用不太到,占位即可
  "private": true,         // 私有标记。设 true 可防止误发布到 npm,也让 pnpm 允许 workspace 结构
  "scripts": {             // ← 定义可用 pnpm run xxx 执行的命令
    "dev":   "cross-env VUE_APP_TITLE=dev vue-cli-service serve",  // 本地开发:起 devServer
    "build": "cross-env VUE_APP_TITLE=production vue-cli-service build"   // 生产打包:输出 dist/
  },
  "dependencies": {        // 运行时依赖:打进最终产物、浏览器里真正要跑的代码
    "vue": "2.6.14",          // Vue2 框架本体。固定精确版本,保证和主/其它子应用一致,避免多实例冲突
    "vue-router": "^3.5.3"    // Vue2 配套路由。^ 表示允许升级到 <4.0.0 的兼容版本
  },
  "devDependencies": {     // 开发时依赖:只在构建/开发用,不打进运行产物
    "@vue/cli-service": "~5.0.0",     // Vue CLI 核心,提供 serve/build 命令(封装 webpack)。~ 只允许补丁升级
    "cross-env": "^7.0.3",            // 跨平台设置环境变量。Win 和 Mac/Linux 设变量语法不同,用它统一
    "vue-template-compiler": "2.6.14" // 编译 .vue 里的 <template>。版本必须和 vue 完全一致,否则报错
  }
}

几个容易混淆的点

1. scripts 里为什么要 cross-env VUE_APP_TITLE=xxx

  • 直接写 VUE_APP_TITLE=dev 在 Windows 上会失败(cmd 不认这种写法),cross-env 抹平差异。
  • VUE_APP_ 前缀是 Vue CLI 约定:只有这个前缀的变量才会被注入到前端代码里(process.env.VUE_APP_TITLE),用来区分环境、选对应的 entry 配置。

2. dependencies vs devDependencies 的界线

  • 判断标准:浏览器运行时是否需要vue/vue-router 会打进产物 → dependencies;构建工具、编译器、只在打包时用 → devDependencies

3. 版本号前缀 ^ ~ 精确 的区别

写法 含义
2.6.14 精确锁定,只装这一版 框架本体、编译器,需强一致
^3.5.3 允许升次版/补丁,不跨大版本 >=3.5.3 <4.0.0
~5.0.0 只允许升补丁 >=5.0.0 <5.1.0

1.2 apps/demo/public/index.html

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <title>demo 子应用</title>
  </head>
  <body>
    <!-- 独立运行时 Vue 会挂载到这里;被 qiankun 接管时会挂到基座给的容器里 -->
    <div id="demo-app"></div>
  </body>
</html>

为什么 :给一个独有的挂载点 id#demo-app),避免和其他子应用/基座的 #app 冲突。

1.3 apps/demo/src/App.vue

vue 复制代码
<template>
  <div class="demo-root">
    <h2>我是 demo 子应用</h2>
    <router-view />
  </div>
</template>

<script>
export default { name: 'DemoApp' }
</script>

1.4 apps/demo/src/router.js

js 复制代码
// 用 render 函数替代字符串 template:
// 原因:Vue 默认引入 runtime-only 构建(不含模板编译器),运行时无法编译 template 字符串。
// 用 render 函数在打包阶段就是最终形态,无需运行时编译,也不用额外打包 vue 编译器包。
const Home = { render: h => h('div', 'demo 首页') }
const About = { render: h => h('div', 'demo 关于') }


export default [
  { path: '/', component: Home },
  { path: '/about', component: About }
]

为什么 :路由用相对路径(//about),真正的前缀 base 会在 main.js 里根据环境动态设置。


步骤 2:配 vue.config.js(打包成 UMD + 允许跨域)

apps/demo/vue.config.js

js 复制代码
// 读取 package.json 的 name(→ 'demo'),用于给 library/chunkLoadingGlobal 拼唯一前缀
// 好处:改包名时这里自动同步,无需手改多处
const packageName = require('./package.json').name

module.exports = {
  // 静态资源(js/css/图片)的基础访问路径
  // - 独立运行时资源在 http://localhost:9101/,用 '/' 即可
  // - 被 qiankun 接管时,会被 src/public-path.js 里的 __webpack_public_path__ 运行时覆盖,避免资源 404
  publicPath: '/',

  // 生产打包不生成 sourcemap:打包更快、产物更小、不暴露源码(代价:线上报错定位到压缩代码)
  productionSourceMap: false,

  // 本地开发服务器配置
  devServer: {
    // 本子应用端口,必须唯一:本地要同时跑主应用(如8080)+多个子应用,端口不能撞车
    port: 9101,
    headers: {
      // ⭐关键:主应用(8080)去 fetch 本子应用(9101)资源属跨域,不放开会被浏览器 CORS 拦截
      'Access-Control-Allow-Origin': '*'
    },
    // 关闭报错全屏遮罩:子应用嵌在主应用中,遮罩会盖住整页,错误仍会打到控制台
    client: { overlay: false }
  },

  // 打包输出配置 ------ 微前端接入的命门,决定 qiankun 能否拿到子应用的生命周期函数
  configureWebpack: {
    output: {
      // 给导出的库起名,[name] 是 webpack 变量(对应 entry 名)
      // 用包名做前缀保证全局唯一:多个子应用同时加载时库名重名会互相覆盖导致白屏
      library: `${packageName}-[name]`,
      // ⭐最关键:UMD 会把入口的 export(bootstrap/mount/unmount) 挂到 window[library名] 上
      // qiankun 抓到子应用 JS 后从这里读取生命周期函数。不配则接入必然失败
      libraryTarget: 'umd',
      // webpack 异步加载 chunk 用的全局 jsonp 回调变量名,加包名前缀避免多应用同页冲突
      chunkLoadingGlobal: `webpackJsonp_${packageName}`
    }
  }
}

步骤 3:写 public-path.js(修正静态资源路径)

apps/demo/src/public-path.js

js 复制代码
// 当被 qiankun 加载时,用 qiankun 注入的路径覆盖 webpack 的 publicPath
if (window.__POWERED_BY_QIANKUN__) {
  // eslint-disable-next-line no-undef
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
}

为什么

  • 独立运行时,子应用资源在 http://localhost:9101/xxx.jspublicPath = '/' 没问题。
  • 但被主应用(比如 http://localhost:8080/demo-app)接管后,如果还用 /,浏览器会去主应用域名下找 demo 的 js/css → 404 白屏
  • qiankun 提前算好了正确的基础路径放在 __INJECTED_PUBLIC_PATH_BY_QIANKUN__,我们在入口最顶部 (下一步会 import 它作为第一行)动态覆盖,webpack 后续加载的所有异步 chunk 就会走正确地址。
  • 注意__webpack_public_path__ 是 webpack 提供的运行时全局变量,直接赋值即可,无需声明。

__POWERED_BY_QIANKUN____INJECTED_PUBLIC_PATH_BY_QIANKUN__它们都是 qiankun 在运行时塞到子应用 window 上的变量,一个管「身份判断」,一个管「资源定位」,配合使用。

一张表看清区别

变量 类型 谁产生 值是什么 作用
window.__POWERED_BY_QIANKUN__ 布尔 qiankun true / undefined 判断「我是否被主应用接管」
window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ 字符串 qiankun 子应用的 entry 地址(如 http://localhost:9101/) 告诉子应用资源该从哪加载

一个是「开关」,一个是「地址」

javascript 复制代码
if (window.__POWERED_BY_QIANKUN__) {                    // ① 开关:现在是被接管状态吗?
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__  // ② 地址:是的话,把资源路径改成这个
}
  • __POWERED_BY_QIANKUN__(开关/身份) 子应用有两种运行方式:独立跑(pnpm run dev)或被主应用加载。这个变量就是让子应用知道自己现在处于哪种状态

    • true → 我嵌在主应用里跑
    • undefined → 我自己独立跑
  • __INJECTED_PUBLIC_PATH_BY_QIANKUN__(地址) 当确认被接管后,需要知道「我的 js/css/图片真实放在哪」。这个变量就是 qiankun 提前算好的资源基础地址(等于主应用给我配的 entry)。

为什么必须两个一起用(先判断,再取值)

关键点: __INJECTED_PUBLIC_PATH_BY_QIANKUN__ 只有在被接管时才存在 。独立运行时它是 undefined

所以顺序是固定的:

arduino 复制代码
先看 __POWERED_BY_QIANKUN__ 是不是 true
   ├─ 是 → __INJECTED_PUBLIC_PATH_BY_QIANKUN__ 才有值,拿来改 publicPath
   └─ 否 → 独立运行,跳过,保持 vue.config.js 里的 publicPath:'/'

如果不先判断就直接取第二个,独立运行时会把 publicPath 设成 undefined,反而把独立开发搞崩。

它俩在整个流程里的位置

csharp 复制代码
主应用注册: { name:'demo-app', entry:'//localhost:9101/', activeRule:'/demo-app' }
        │ 用户访问 /demo-app,qiankun 开始加载 demo
        ▼
qiankun 往子应用 window 注入两个变量:
   __POWERED_BY_QIANKUN__            = true                      ← 身份开关
   __INJECTED_PUBLIC_PATH_BY_QIANKUN__ = 'http://localhost:9101/' ← 资源地址(来自 entry)
        │
        ▼
public-path.js:  if(开关为true){ __webpack_public_path__ = 资源地址 }
        │
        ▼
main.js 里还会再用一次开关 __POWERED_BY_QIANKUN__:
   - 决定路由 base 用 '/demo-app' 还是 '/'
   - 决定要不要自己 render()(独立运行才自己启动)

划重点

  • 两个都是 qiankun 注入的、你不用自己定义;独立运行时它们都不存在。
  • __POWERED_BY_QIANKUN__ 用在两个地方public-path.js(要不要改路径)和 main.js(路由 base、要不要自启动)。
  • __INJECTED_PUBLIC_PATH_BY_QIANKUN__ 只用在 public-path.js 一处,且必须在开关为真的前提下取。
  • 记忆口诀:先问「我被接管了吗」(POWERED_BY),再问「资源在哪」(INJECTED_PUBLIC_PATH)

步骤 4:写 main.js(导出生命周期)

apps/demo/src/main.js

js 复制代码
import './public-path' // 必须是第一行 保证在加载其他资源前修正publicPath
import Vue from 'vue'
import VueRouter from 'vue-router'
import App from './App.vue'
import routes from './router'

// 安装 vue-router 插件:会全局注入 $router/$route,并注册 <router-view>/<router-link> 组件
Vue.use(VueRouter)
// 关闭生产环境提示,避免控制台输出多余信息
Vue.config.productionTip = false
// qiankun 注入的全局标志:true 表示被主应用接管,false/undefined 表示独立运行
const isQiankun = window.__POWERED_BY_QIANKUN__

// Vue 根实例(挂载后赋值,unmount 时销毁)
let instance = null
// VueRouter 实例(每次 mount 都会重新创建)
let router = null

// 真正创建并挂载 Vue 应用的函数,独立运行和被 qiankun 接管时都会调用
function render(props = {}) {
  // qiankun 会通过 props 传入主应用提供的容器节点 container
  const { container } = props
  // 创建路由实例
  router = new VueRouter({
    mode: 'history', // 使用 HTML5 history 模式无 # 号的 URL)
    base: isQiankun ? '/demo-app' : '/', // 被接管时加基础路径前缀,独立运行时用根路径
    routes
  })
  // 创建 Vue 根实例,通过渲染函数挂载根组件 App
  instance = new Vue({
    router,
    render: h => h(App)
    // 被接管时挂载到主应用容器内的 #demo-app,独立运行时挂载到全局 #demo-app
  }).$mount(container ? container.querySelector('#demo-app') : '#demo-app')
}

// 独立运行(非 qiankun 环境)时直接渲染
if (!isQiankun) {
  render()
}

// ==================== qiankun 生命周期钩子 ====================

// bootstrap:应用首次初始化时调用,整个生命周期只执行一次,用于做只需一次的准备工作
export async function bootstrap() {
  console.log('[demo] bootstrap')
}

// mount:每次进入子应用时调用,负责渲染页面(可能被次调用)
export async function mount(props) {
  console.log('[demo] mount', props)

  render(props)
}

// unmount:每次离开子应用时调用,负责销毁实例、清理资源,避免内存泄漏
export async function unmount() {
  console.log('[demo] unmount')
  instance.$destroy() // 销毁 Vue 实例,触发 beforeDestroy/destroyed,解绑事件与 watcher
  instance.$el.innerHTML = '' // 清空挂载点内的 DOM 内容
  instance = null // 释放实例引用
  router = null // 释放路由引用
}

为什么这样写

  • import './public-path' 必须第一行:webpack 处理模块有顺序,publicPath 要在任何资源加载前生效。
  • isQiankun 分流:这套代码既能被基座加载,又能 npm run dev 单独跑(开发子应用时不必每次都起主应用),这是工程上非常实用的做法。
  • base 动态设置:被接管时子应用的真实 URL 是 /demo-app/about,路由 base 必须是 /demo-app,否则路由匹配错乱。
  • mount 里通过 container.querySelector('#demo-app') 挂载:qiankun 会把子应用内容放进基座给的 container(即基座的 #subapp-viewport),我们要挂到这个容器内部#demo-app
  • unmount 必须彻底销毁:微前端里子应用会被反复挂载/卸载,清理会内存泄漏、事件重复绑定、路由报错。

步骤 5:在主应用 micro-app.js 注册

打开主应用的子应用清单文件(通常是 apps/main/src/micro-app.js),追加一条:

js 复制代码
const microApps = [
  // ...已有的子应用...
  {
    name: 'demo-app', // 唯一名字,建议和 activeRule 呼应
    entry: envConfig.VUE_APP_SUB_DEMO_APP,   // 入口 URL,从环境配置取(步骤6定义)
    activeRule: envConfig.VUE_APP_RULE_DEMO_APP // 路由规则,从环境配置取
  }
]

// 通常主应用还会统一给每个子应用补充 container / props(沿用已有写法即可)
const apps = microApps.map(item => ({
  ...item,
  container: '#subapp-viewport', // 子应用挂载容器(基座里那个空 div)
  props: {
    routerBase: item.activeRule // 把路由前缀下发给子应用
    // user: store.state.user,   // 需要的话把用户信息等一并下发
  }
}))

export default apps

为什么

  • entry / activeRule 不写死,而是从环境配置读取------因为本地和线上地址不同(见步骤 6)。
  • container: '#subapp-viewport':这是主应用布局里预留的空容器(<div id="subapp-viewport"></div>),所有子应用都挂到这里。
  • props.routerBase:把 /demo-app 传给子应用,子应用 mount 时可用它设置路由 base(比在子应用里硬编码更灵活)。

主应用只要在启动时 registerMicroApps(apps) + start()(一般已有,无需改动),新子应用就接入了。



步骤 6:在环境配置里加 entry / rule

打开环境配置文件(通常是 apps/main/public/static/env/1.0.0/index.js 之类),给每个环境都补上 demo 的两项:

js 复制代码
const config = {
  // 本地开发环境
  dev: {
    // ...已有配置...
    VUE_APP_SUB_DEMO_APP: '//localhost:9101/', // 本地:指向 demo 的 devServer 端口
    VUE_APP_RULE_DEMO_APP: '/demo-app'          // 路由规则,各环境保持一致
  },
  // 线上/预发环境
  production: {
    // ...已有配置...
    VUE_APP_SUB_DEMO_APP: '/demo-app/',         // 线上:同域路径,由 Nginx 分发
    VUE_APP_RULE_DEMO_APP: '/demo-app'
  }
}

为什么 entry 分环境不同、rule 却一致

  • 本地 各子应用是独立 devServer,entry 必须带端口//localhost:9101/),主应用才 fetch 得到。
  • 线上 所有子应用打包后由同一个 Nginx 托管在同域下,entry 用路径/demo-app/),Nginx 按路径把请求指到 demo 的静态资源目录。
  • activeRule/demo-app)是路由匹配逻辑,与部署方式无关,所以所有环境保持一致,保证行为统一。

相关推荐
JarvanMo2 小时前
Dart 3.13:小版本,大动作
前端
纯粹的热爱2 小时前
Ubuntu 安装 Node.js 22.x(通过 NodeSource 官方源)
前端
Highcharts2 小时前
如何选择正确的图表:驱动理解和洞察力的三部分框架(第3部分)
前端·数据可视化
JavaGuide2 小时前
Github 史诗级故障,与此同时,Cursor 版「GitHub」正式上线!
前端·后端
郭邯2 小时前
用 AI 写了一个经纬度格式转换工具,从需求到落地的完整过程
前端
不一样的少年_2 小时前
修了 Bug、做了重构,为什么老板还是觉得你没产出?
前端·后端·程序员
马可家的菠萝2 小时前
Vue3 + Canvas 手绘笔记工程化实践:别把画布只当成一张 PNG
前端·vue.js·算法
IMPYLH2 小时前
HTML 的 <h1>–<h6> 元素
前端·javascript·html
IMPYLH2 小时前
HTML 的 <head> 元素
前端·html