动手实验 ------ 新增一个 demo 子应用
目标:在一个「已有主应用 + 若干子应用」的 monorepo 里,新增一个名为
demo的子应用,让它能通过/demo-app路由被主应用加载。 假设你的仓库结构类似:apps/main(主应用)、apps/xxx(各子应用)。我们要新增apps/demo。
步骤总览
- 新建
apps/demo目录与基础文件 - 配
vue.config.js(打包成 UMD + 允许跨域) - 写
public-path.js(修正静态资源路径) - 写
main.js(导出生命周期 bootstrap/mount/unmount) - 在主应用
micro-app.js注册这个子应用 - 在环境配置里加 entry / activeRule
- 启动验证
步骤 1:新建 apps/demo 目录与基础文件
方式 A(最快 · 推荐先用):复制现有子应用改名
已有子应用(如
apps/app1)的结构和微前端接入代码是现成的、已验证过的。直接复制一份再改差异点最省心:
bashcp -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 处改造
bashnpx @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.js,publicPath = '/'没问题。- 但被主应用(比如
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)是路由匹配逻辑,与部署方式无关,所以所有环境保持一致,保证行为统一。