前言

基于 microapp 的微前端实操

安装

microapp 需要基座项目+若干子项目这里以如下三个项目为例子

1
2
3
vue create base
vue create first_child
vue create second_child


Cd 到基座项目 安装 microapp

1
pnpm install @micro-zoe/micro-app --save

在基座中的 main.ts 中引入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import microApp from "@micro-zoe/micro-app";
microApp.start({
// microApp生命周期
lifeCycles: {
created(e) {
console.log("created");
},
beforemount(e) {
console.log("beforemount");
},
mounted(e) {
console.log("mounted");
},
unmount(e) {
console.log("unmount");
},
error(e) {
console.log("error", e);
},
},
});

createApp(App).use(router).mount("#app");

模式

microapp 支持如下的路由模式
基座应用是 hash 的话 子应用必须是 hash
基座应用是 history 的话 子应用是 history 或者 hash

我这边的配置是基座 histroy 子引用 hash
所以先改一下基座的配置

基座 history 模式的配置

router.ts中修改

1
2
3
4
5
const router = createRouter({
// history: createWebHashHistory(),
history: createWebHistory("/base"),
routes,
});

vue.config.js 中修改

1
2
3
4
5
6
7
8
9
10
module.exports = {
publicPath: "/base", // 主要是这个配置
devServer: {
host: "localhost",
port: 3000,
headers: {
"Access-Control-Allow-Origin": "*",
},
},
};

子项目由于 vue 初始化的时候就是默认的 hash 所以就不改了

基座路由配置

router.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import {
createRouter,
// createWebHashHistory,
createWebHistory,
RouteRecordRaw,
} from "vue-router";
import HomeView from "../views/HomeView.vue";

const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "home",
component: HomeView,
},
{
path: "/micro-app1/*", // 这里对应着micro的组件中的baseroute
name: "child1", // 这里对应着micro组件中的name
component: () => import("@/views/app_first.vue"),
},
{
path: "/micro-app2/*",
name: "child2",
component: () => import("@/views/app_second.vue"),
},
];

const router = createRouter({
// history: createWebHashHistory(),
history: createWebHistory("/base"),
routes,
});

export default router;

创建子项目 vue app_first.vue 保证 namebaseroute 对应 且其中的 url 是子项目启动地址
(由于基座是 history 子项目是 hash 所以组件不需要设置 baseroute)
url 属性 html 地址 作用是加载 html 资源

1
2
3
4
5
<template>
<micro-app name="child1" url="http://localhost:1111/"></micro-app>
</template>

<script setup lang="ts"></script>

app_second.vue

1
2
3
4
5
<template>
<micro-app name="child2" url="http://localhost:1112/"></micro-app>
</template>

<script setup lang="ts"></script>

基座 app.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<template>
<div>
<app_first></app_first>
<div class="app_second">
<app_second></app_second>
</div>
</div>
</template>
<script setup lang="ts">
import app_first from "@/views/app_first.vue";
import app_second from "@/views/app_second.vue";
</script>
<style lang="scss">
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}

nav {
padding: 30px;

a {
font-weight: bold;
color: #2c3e50;

&.router-link-exact-active {
color: #42b983;
}
}
}
.app_second {
width: 100px;
height: 400px;
}
</style>

子项目配置

配置跨域 和 port

子项目 1 端口是 1111 子项目 2 端口我写了 1112

1
2
3
4
5
6
7
8
9
10
module.exports = {
// publicPath: "/micro-app1",
devServer: {
host: "localhost",
port: 1111,
headers: {
"Access-Control-Allow-Origin": "*",
},
},
};

app.vue

子项目随便在 app.vue 中写点东西

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<template>
<nav>这个是第一个app</nav>
</template>

<style lang="scss">
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}

nav {
padding: 30px;

a {
font-weight: bold;
color: #2c3e50;

&.router-link-exact-active {
color: #42b983;
}
}
}
</style>

第二个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<template>
<nav>这个是第二个app</nav>
</template>

<style lang="scss">
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}

nav {
padding: 30px;

a {
font-weight: bold;
color: #2c3e50;

&.router-link-exact-active {
color: #42b983;
}
}
}
</style>

启动

如果不是模块空间的话,就手动启动三个项目吧

1
pnpm run serve

出现的界面如下 则成功

应用间跳转

基座控制子应用跳转

原理是

注意事项:如果两个子应用的 path 是一样的 虽然 setdata 会触发相应的页面跳转,但是直接在网址上跳转的话两个子应用都会被触发

子项目 1 配置

App.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<div>这个是第一个app</div>
<RouterView></RouterView>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";

const router = useRouter();
// 监听基座下发的数据变化
if (window?.microApp?.addDataListener) {
window.microApp.addDataListener((data: any) => {
console.log(data.path, "datapath");
// 当基座下发跳转指令时进行跳转
if (data.path) {
router.push(data.path);
}
});
}
</script>

子项目 1 的测试跳转页面为自带的 about

子项目 2 配置

App.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<div>这个是第二个app</div>
<RouterView></RouterView>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";

const router = useRouter();
// 监听基座下发的数据变化
if (window?.microApp?.addDataListener) {
window.microApp.addDataListener((data: any) => {
console.log(data.path, "datapath");
// 当基座下发跳转指令时进行跳转
if (data.path) {
router.push(data.path);
}
});
}
</script>

子项目 2 的测试跳转页面同样为自带的 about 页 但是 path 要写不一样

主项目 base 配置

App.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<template>
<div>
<app_first></app_first>
<div class="app_second">
<app_second></app_second>
</div>
<button @click="routeToProject('child1')">跳转项目1</button>
<button @click="routeToProject('child2')">跳转项目2</button>
</div>
</template>
<script setup lang="ts">
import microApp from "@micro-zoe/micro-app";
import app_first from "@/views/app_first.vue";
import app_second from "@/views/app_second.vue";
type routerType = "child1" | "child2";
const routeToProject = (type: routerType) => {
console.log(type, "type");
const typeMapper = {
child1: "/about/",
child2: "/about2/",
};
microApp.setData(type, { path: typeMapper[type] });
};
</script>
<style lang="scss">
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}

nav {
padding: 30px;

a {
font-weight: bold;
color: #2c3e50;

&.router-link-exact-active {
color: #42b983;
}
}
}
.app_second {
width: 100px;
height: 400px;
}
</style>

测试

跳转项目 1

跳转项目 2

注意:我们发现跳转的时候 url 变了,但是跳转的过程中并没有重置之前项目 1 的状态,也就是项目 1 的状态仍然被保留了下来
我们试着刷新页面

发现此时子项目 1 的状态才被重置。
解决方法也很简单 给 routerToProject 方法提供一个还原其他页面状态的操作就 ok 了 这里就不写了

子应用控制基座跳转

如果有基座控制子应用跳转 那么自然有子应用控制基座跳转
原理是利用了回调函数的写法 给子项目传递一个回调函数去触发

bug 探讨

在使用了上述方法 流程是 base 项目触发跳转子项目 1,子项目 1 提供按钮回退到 base 项目
此时拿不到 pushState 或者说是 window.mircoApp.getData 的数据被刷新了

但是初始化的时候是存在这个内容的

所以我们得通过监听下发通知的方式去触发跳转,或者直接通过状态管理去存储初次的状态 后续就直接调用
pinia 代码 /store/useMicro.ts

1
2
3
4
5
6
7
8
import { defineStore } from "pinia";
import { ref } from "vue";
export const useMicro = defineStore("useMicro", () => {
const microData = ref<any>(null);
return {
microData,
};
});

子项目 1 的 App.vue 此时就收集到了基座项目的 pushState 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<template>
<div>这个是第一个app</div>
<RouterView></RouterView>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import { useMicro } from "@/store/useMicro";
import { storeToRefs } from "pinia";
const { microData } = storeToRefs(useMicro());
const router = useRouter();
// 监听基座下发的数据变化
if (window?.microApp?.addDataListener) {
window.microApp.addDataListener((data: any) => {
console.log(data.path, "datapath");
// 当基座下发跳转指令时进行跳转
if (data.path) {
router.push(data.path);
}
});
microData.value = window.microApp.getData();
console.log(microData.value, "window.microApp.getData()");
}
</script>

对应页面回调出去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<template>
<div class="about">
<h1>This is an about page child1</h1>
<button @click="routeToBaseProject">基座跳转回首页</button>
</div>
</template>

<script lang="ts" setup>
// import { nextTick } from "vue";
import { useMicro } from "@/store/useMicro";
import { storeToRefs } from "pinia";
const { microData } = storeToRefs(useMicro());
const routeToBaseProject = () => {
// nextTick(() => {
if (window?.microApp?.getData) {
console.log(window.microApp.getData(), "window.microApp");
// window.microApp.getData().pushState("/");
}
console.log(microData.value, "microData.value");
microData.value.pushState("/baseTest");
// });
};
</script>

多项目启动

一般来说 npm 不支持平行启动多个项目,参考了 microapp 仓库,他的 packagejson 里面有如下的东西

将子项目和主项目分开 然后通过一个 run-all 的命令去全部执行
于是找到了对应的 npm 仓库

1
pnpm install npm-run-all --save-dev

从项目根目录下初始化一个 packagejson

1
2
3
4
5
6
7
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev-child:child1": "cd first_child && pnpm run serve",
"dev-child:child2": "cd second_child && pnpm run serve",
"dev-main:base": "cd base && pnpm run serve",
"dev-main": "npm-run-all --parallel dev-main:base dev-child:_"
},

结果:

多项目打包

1
2
3
4
5
6
{
"build-main:base":"cd base && pnpm run build",
"build-child:child1":"cd first_child && pnpm run build",
"build-child:child2":"cd second_child && pnpm run build",
"build-main":"npm-run-all --parallel build-main:base build-child:_"
}

部署项目(本地 docker

先修改之前 base 项目的 router,因为这样方便我们后续操作,将原来叫 micro_app1 和 2 的路径分别改为子项目的名字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import {
createRouter,
// createWebHashHistory,
createWebHistory,
RouteRecordRaw,
} from "vue-router";
import HomeView from "../views/HomeView.vue";

const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "home",
component: HomeView,
},
{
path: "/baseTest",
name: "baseTest",
component: () => import("@/views/AboutView.vue"),
},
{
path: "/first_child/*",
name: "child1",
component: () => import("@/views/app_first.vue"),
},
{
path: "/second_child/*",
name: "child2",
component: () => import("@/views/app_second.vue"),
},
];

const router = createRouter({
// history: createWebHashHistory(),
history: createWebHistory("/base"),
routes,
});

export default router;

Output dir 配置打包路径(可以不写,参见 Dockerfile)

我们继续编写各个 vueconfigjs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// base 项目
module.exports = {
publicPath: "/base",
devServer: {
host: "localhost",
port: 3000,
headers: {
"Access-Control-Allow-Origin": "_",
},
},
outputDir: "base",
// vue.config.js
chainWebpack: (config) => {
config.module
.rule("vue")
.use("vue-loader")
.tap((options) => {
options.compilerOptions = {
...(options.compilerOptions || {}),
isCustomElement: (tag) => /^micro-app/.test(tag),
};
return options;
});
},
};
// first_child 项目
module.exports = {
// publicPath: "/micro-app1",
devServer: {
host: "localhost",
port: 1111,
headers: {
"Access-Control-Allow-Origin": "_",
},
},
outputDir: "first_child",
};
// second_child 项目
module.exports = {
// publicPath: "/micro-app2",
devServer: {
host: "localhost",
port: 1112,
headers: {
"Access-Control-Allow-Origin": "*",
},
},
outputDir: "second_child",
};

编写 default.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
server {
listen 80;
server_name localhost;

location / {
root /usr/share/nginx/html;
index index.php index.html index.htm; # add_header Cache-Control;
add_header Access-Control-Allow-Origin _;
if ( $request_uri ~_ ^.+.(js|css|jpg|png|gif|tif|dpg|jpeg|eot|svg|ttf|woff|json|mp4|rmvb|rm|wmv|avi|3gp)$ ){
add_header Cache-Control max-age=7776000;
add_header Access-Control-Allow-Origin \*;
}
}

# 主应用 base

location /base {
root /usr/share/nginx/html;
add_header Access-Control-Allow-Origin _;
if ( $request_uri ~_ ^.+.(js|css|jpg|png|gif|tif|dpg|jpeg|eot|svg|ttf|woff|json|mp4|rmvb|rm|wmv|avi|3gp)$ ){
add_header Cache-Control max-age=7776000;
add_header Access-Control-Allow-Origin \*;
}
try_files $uri $uri/ /base/index.html;
}

# 子应用 first_child

location /first_child {
root /usr/share/nginx/html;
add_header Access-Control-Allow-Origin _;
if ( $request_uri ~_ ^.+.(js|css|jpg|png|gif|tif|dpg|jpeg|eot|svg|ttf|woff|json|mp4|rmvb|rm|wmv|avi|3gp)$ ){
add_header Cache-Control max-age=7776000;
add_header Access-Control-Allow-Origin \*;
}
try_files $uri $uri/ /first_child/index.html;
}

# 子应用 second_child

location /second_child {
root /usr/share/nginx/html;
add_header Access-Control-Allow-Origin _;
if ( $request_uri ~_ ^.+.(js|css|jpg|png|gif|tif|dpg|jpeg|eot|svg|ttf|woff|json|mp4|rmvb|rm|wmv|avi|3gp)$ ){
add_header Cache-Control max-age=7776000;
add_header Access-Control-Allow-Origin \*;
}
try_files $uri $uri/ /second_child/index.html;
}

error_page 404 /404.html;
location = /40x.html {
}

error_page 500 502 503 504 /50x.html;
location = /50x.html {
}
}

Dockerfile

注意在 windowmkdir 会产生一个额外的目录 不过问题不大

1
2
3
4
5
6
7
FROM nginx
RUN mkdir -p /usr/share/nginx/html/{base,first_child,second_child}
COPY /base/base/ /usr/share/nginx/html/base
COPY /first_child/first_child/ /usr/share/nginx/html/first_child
COPY /second_child/second_child/ /usr/share/nginx/html/second_child

COPY default.conf /etc/nginx/conf.d/default.conf

如果在上述 outputdir 里面没有编写打包名称的话 问题也不大 道理是一样的 可以这样写

1
2
3
4
5
6
7
FROM nginx
RUN mkdir -p /usr/share/nginx/html/{base,first_child,second_child}
COPY /base/dist/ /usr/share/nginx/html/base
COPY /first_child/dist/ /usr/share/nginx/html/first_child
COPY /second_child/dist/ /usr/share/nginx/html/second_child

COPY default.conf /etc/nginx/conf.d/default.conf

运行 docker

1
2
3
4
5
6
7
8
9
10
docker pull nginx//拉 nginx
docker images// 查看镜像
docker build -t docker_micro_app . --no-cache//构建镜像 记得加.
dokcer run -d -p 5555:80 --name micro_app docker_micro_app // 运行容器
docker exec -it 容器 id bash // 进入容器
cd /usr/share/nginx/html // 进入 nginx 内部的 html
ls //查看所有文件 如下图 说明已经有对应的文件了
exit //退出
// 如果不对 请删除镜像再操作
docker rm -f xxxID

在地址栏查看 localhost:5555/base 如果有内容则成功

运行到本地 ip

1
dokcer run -d -p 你的 ip:5555:80 --name micro_app docker_micro_app // 运行镜像

常见问题

基座应用警报

1
vue3: [Vue warn]: Failed to resolve component: micro-app

解决

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// vue.config.js
module.exports = {
chainWebpack: (config) => {
config.module
.rule("vue")
.use("vue-loader")
.tap((options) => {
options.compilerOptions = {
...(options.compilerOptions || {}),
isCustomElement: (tag) => /^micro-app/.test(tag),
};
return options;
});
},
};