为什么需要模块联邦

  • 模块共享:支持跨应用间共享模块,比如共享组件
  • 按需加载:支持运行时远程动态加载模块,减少打包体积
  • 团队自治:可以实现微前端架构,各个模块支持独立开发和部署,降低团队沟通成本,提升迭代效率

概念

  • 本地应用(host):负责加载并消费来自其他应用的模块
  • 远程应用(remote):负责暴露模块给其他应用消费

方案

基本用法

因为目前本人的项目都是用 vite 构建,所以使用 vite-plugin-federation 展示模块联邦的实践方式

初始化项目

使用 浅析 monorepo 形式搭建,这样就不用同时打开多个编辑器管理多个应用

首先初始化根目录

pnpm init

创建两个应用,一个本地应用 host,消费来自远程应用的模块,一个远程应用 remote,暴露模块给本地应用,两个应用都是 vue3 + vite 技术栈

初始化 host

pnpm create vue host

初始化 remote

pnpm create vue remote

所有包安装依赖

pnpm install

配置 remote

安装 vite-plugin-federation 插件

pnpm add @originjs/vite-plugin-federation --save-dev --filter remote

vite.config.ts 配置插件

import federation from "@originjs/vite-plugin-federation";
 
export default defineConfig({
  plugins: [
    federation({ 
      name: "remote",
      filename: "remoteEntry.js",
      exposes: {
        "./HelloWorld": "./src/HelloWorld.vue",
      },
      shared: ["vue"],
    }),
  ],
});
  • name: 远程应用名称,必填
  • filename: 远程应用入口文件,默认为 remoteEntry.js
  • exposes: 远程应用暴露的模块列表,key 为本地应用导入模块时的路径,value 为远程应用暴露模块的实际路径
  • shared: 本地应用与远程应用共享的依赖,本地和远程都要配置

因为只有打包时才会生成 remoteEntry.js 文件,所以先尝试打包看看打包产物

pnpm --filter remote build

执行你会发现出现了 Top-level await 的报错,这是因为该插件支持静态导入,所谓静态导入就是下面这种:

import myButton from 'remote/myButton';

有静态导入自然就有动态导入:

const myButton = defineAsyncComponent(() => import('remote/myButton'));

回到正题,根据插件文档说明,出现 Top-level await 的报错就是因为静态导入使用了这个特性,但这个特性要考虑浏览器兼容性,因此要调整一下打包配置

export default defineConfig({
  build: { 
    target: 'esnext'
  }
})

再重新执行一下打包命令就能在 remote/dist/assets/ 下找到 remoteEntry.js

有了入口文件就要提供给 host 访问,可以使用 vite preview 基于构建产物开启远程应用的本地服务

为了防止端口冲突,将远程应用跑在 5001 端口

pnpm preview --port 5001

配置 host

remote 一样要安装插件

然后配置 host 的配置

import federation from '@originjs/vite-plugin-federation'
 
export default defineConfig({
  plugins: [
    federation({ 
      name: 'host',
      remotes: {
        remote: 'http://localhost:5001/assets/remoteEntry.js'
      },
      shared: ['vue']
    })
  ],
})

remotes 表示消费哪些远程应用的模块,key 为远程应用的 namevalue 为可以访问远程应用入口文件的路径

然后就需要在本地应用中导入远程应用暴露的模块

<script setup lang="ts">
import AnotherHelloWorld from 'remote/HelloWorld'
</script>
 
<template>
	<AnotherHelloWorld msg="You did it! again!" />
</template>
 

如果 remote/HelloWorld 报红,可能是因为 ts 不认识这个模块,可以使用在 *.d.ts 类型声明中加上 declare module 'remote/*' {}

到此为止就可以运行 host 看看效果

pnpm --filter host dev

如果在页面上看到来自远程应用的组件,那么恭喜你,你已经成功实现最基础的模块联邦了

调试

前面提到,remote 只有打包构建时才能生成入口文件,因此如果我们想要达到每次修改远程应用的模块后类似热更新的效果,可以开启 --watch 监听文件更改并自动打包

pnpm --filter remote build --watch

同时要开启本地服务

pnpm --filter remote perview

可惜这种方式每次修改完后还是要手动刷新下页面

路由导航

前面只做到了加载远程应用的某个组件,如果想要加载整个远程应用作为本地应用的一部分,同时远程应用还包含了自身路由,会产生几个问题:

如何加载远程应用的路由?

如何支持前进后退?

我们知道要使用 vue-router,就必须创建路由器实例并在 vue 应用实例上注册为插件,那按理说,要使用远程应用的路由,我们应该暴露远程应用的路由模块给本地应用加载,但从本地应用角度来说,其实不太想关心远程应用本身存在哪些路由,而是远程应用整体加载后能否正常使用,从远程应用来说,如果本地应用和远程应用分别由不同团队负责,负责远程应用的团队不确定要暴露多少模块给本地应用才能满足需求,势必会加大沟通成本

换个角度说是不是把远程应用的 vue 应用实例挂载进本地应用,也能使用远程应用自身的路由呢?

想要把应用实例挂载进来就必须调用 mount 方法,并指定要挂载的容器元素,因为本地应用已经挂载到 #app 元素,我们不能同时挂载两个应用实例到同一个容器元素上,所以需要新建一个组件作为远程应用的容器

在此之前,先提取远程应用上 main.ts 中的一系列的初始化流程

import bootstrap from './bootstrap' 
 
const el = document.querySelector('#app')
el && bootstrap(el)

新建 bootstrap.ts

import './assets/main.css'
 
import { createApp } from 'vue'
import { createPinia } from 'pinia'
 
import App from './App.vue'
import router from './router'
 
export default function bootstrap(el: Element) { 
  const app = createApp(App)
 
  app.use(createPinia())
  app.use(router)
 
  app.mount(el)
}

导出 bootstrap 启动函数模块

export default defineConfig({
  plugins: [
    federation({
	  // ...
      exposes: {
        './bootstrap': './src/bootstrap.ts'
      },
	  // ...
    })
  ],
})

在本地应用中新建组件 RemoteView.vue 作为远程应用的挂载容器,同时为其新建一个路由

<script setup lang="ts">
import bootstrap from 'remote/bootstrap'
import { onMounted, ref } from 'vue'
 
const remoteRef = ref()
 
onMounted(() => {
  bootstrap(remoteRef.value)
})
</script>
 
<template>
  <div ref="remoteRef"></div>
</template>

如果调用 bootstrap时报红,可能是因为 ts 不认识这个函数,可以使用在 *.d.ts 类型声明中加上 declare module 'remote/*' { export default function bootstrap(el: Element): void }

const router = createRouter({
  // ...
  routes: [
    {
      path: '/remote',
      name: 'remote',
      component: () => import('../views/RemoteView.vue')
    }
  ]
})
 
export default router

现在分别运行以下命令启动项目

pnpm --filter host dev --port 5000
pnpm --filter remote build --watch
pnpm --filter remote preview --port 5001

打开 http://localhost:5000/remote 可以看到两个应用都显示了出来,如果现在点击远程应用上的路由跳转,你就会发现实际跳转的是本地应用而非远程应用自身的路由,这是因为远程应用和本地应用都使用了 history 路由,路径的变化会反应到页面的组件渲染上,当尝试点击 about 链接跳转时,url 会变成 http://localhost:5000/about ,正好匹配到本地应用的路由,所以跳转的是本地应用的页面

解决方案就是,设置远程应用的基准路径为本地应用访问远程应用的路由路径 /remote,同时将本地应用访问远程应用的路由改为了动态路由匹配,这样在访问 /remote 下任意路由时都能匹配到远程应用组件,同时匹配路径的变化,即使刷新也没问题

远程应用

const router = createRouter({
  history: createWebHistory('/remote'),
  // ...
})
 
export default router

本地应用

const router = createRouter({
  // ...
  routes: [
    {
      path: '/remote/:id*',
      name: 'remote',
      component: () => import('../views/RemoteView.vue')
    }
  ]
})
 
export default router

这里出现了一个问题:

本地应用通过远程应用跳转到需要动态加载组件的页面时,网络请求报 404 错误

404 错误发生的原因通过 开发者工具 - Network - 选择报错的请求 - initial 查看哪行代码发起请求,发现发起请求的方式是通过在 head 标签中插入 link 元素实现的,link 元素的 href 资源链接就是远程应用打包出来的动态组件模块,但是是相对路径,所以导致 link 元素插入到本地应用后对应资源从本地应用所在 ip 和端口发起,而实际资源却在远程应用路径下,最终导致 404

解决办法有两种,一种是配置 vite 的 base 选项,指定资源的绝对路径,不用相对路径,另一种则是放弃动态 import,直接打包进入口文件中,我采用第一种好处多一点,反正后面可以动态配置

export default defineConfig({
  // ...
  base: 'http://localhost:5001', // [!code focus]
})

回最开始提出的第二个问题:

如何支持前进后退?

目前为止看上去是支持前进后退的,但是有个小 bug:

会出现后退成 http://localhost:5000/remote/remote 的情况

仔细想想其他完全没必要关心远程应用的路由是否和 URL 对应,通过后退前进按钮控制导航的场景十分少,大多数还是通过自定义按钮指定页面跳转,既然这样,可以使用路由的 memory 模式,即页面跳转不会反应到 URL 上,甚至不需要考虑设置远程应用的基准路径

远程应用

import { createMemoryHistory, createRouter } from 'vue-router'
 
const router = createRouter({
  history: createMemoryHistory(), 
  // ...
})
 
export default router

本地应用

const router = createRouter({
  // ...
  routes: [
    {
      path: '/remote',
      name: 'remote',
      component: () => import('../views/RemoteView.vue')
    }
  ]
})
 
export default router

缺点可能是无法通过输入 URL 实现对远程应用自身路由的跳转,还要注意如果远程应用需要单独部署访问,最后区分一下路由模式,单独部署访问时使用 history 模式,作为本地应用一部分时使用 memory 模式

至此,个人认为已经满足了微前端架构中基本的导航需求

数据共享

微前端架构中,跨应用间共享数据十分重要,比如两个应用都要向后端发起请求拿数据,但前提是请求中要携带登录后的 token,那么这两个应用要共享同一个 token

客户端存储共享

对于少量的数据,也是可以客户端存储的方式共享,比如 cookie、localStorage、sessionStore

根组件共享

vue 文档上有介绍在使用 createApp 创建应用实例时是可以传递 props 给根组件的,也就是说在本地应用上挂载远程应用时可以传递数据

远程应用 - bootstrap.ts

// ...
export default function bootstrap(el: Element) {
  const app = createApp(App, { token: 'Reiner' }) // [!code focus]
  // ...
}

远程应用 - App.vue

<script setup lang="ts">
// ...
defineProps({ 
  token: String
})
</script>

确认远程应用可以通过 createApp 传递数据后,改为从本地应用传递数据

远程应用 - bootstrap.ts

// ...
export default function bootstrap(el: Element, shareData: Record<string, any> = {}) {
  const app = createApp(App, shareData) 
  // ...
}

本地应用 - RemoteView.vue

<script setup lang="ts">
// ...
onMounted(() => {
  bootstrap(remoteRef.value, { token: 'Reiner' }) 
})
</script>

store 共享

在组件间共享数据的状态管理库是否也能在应用间共享呢?这里用 Pinia 演示

要实现也很简单,只要两个应用共用一个 pinia 实例就好

远程应用

import { createPinia } from 'pinia'
 
export * from './counter'
 
export const pinia = createPinia()
// ...
import { pinia } from './stores' // [!code focus]
 
export default function bootstrap(el: Element, shareData: Record<string, any> = {}) {
  // ...
  app.use(pinia) // [!code focus]
  // ...
}

暴露远程应用的 pinia 实例

// ...
export default defineConfig({
  plugins: [
	// ...
    federation({
      // ...
      exposes: {
		// ...
        './stores': './src/stores/index.ts'
      },
      // ...
    })
  ],
  // ...
})

本地应用

// ...
import { pinia } from 'remote/stores'
// ...
app.use(pinia)
// ...
<script setup lang="ts">
// ...
import { useCounterStore } from 'remote/stores'
 
const counter = useCounterStore() 
</script>

测试一下,你就会发现,it works! 在本地应用更新 store 也会同步到远程应用

那么问题来了,假设还有另外一个远程应用需要和本地应用同步 store,该使用谁的 pinia 实例?

本地应用可以使用多个 pinia 实例吗?不行,只会弹出 App already provides property with key "Symbol(pinia)". It will be overwritten with the new value. 警报

从本地应用暴露 pinia 实例给其他远程应用?虽然这样远程应用和本地应用有点耦合到了一起,但要注意远程应用独立部署访问时使用自己的 pinia 实例

其实我认为微前端的主要目的是为了按业务领域拆分应用,应用间隔离业务数据的意义大于数据共享

样式隔离

如果本地应用和远程应用都定义了相同的类名,那么极大可能会产生冲突覆盖且难以排查来源,那么就有必要对样式进行隔离了,让模块和样式封装在一起,不影响外部也不被外部影响

常见方案

  • BEM
  • CSS in JS
  • CSS Modules
  • Vue Scoped

这里我可以直接说答案,BEM + CSS 预处理器 + Vue Scoped 就是最方便的解决方案

BEM

是一种命名规范,目的是编写可维护的,结构清晰的 CSS 代码

它将类名分为三部分:

  • Block:块,表示独立的组件或功能模块
  • Element:元素,用 __ 双下划线接在块后边
  • Modifier:修饰符,用 -- 双连字符接在元素后边,表示元素的状态

不一定要死板地使用这种规范,其本质思想是通过限制类的作用范围达到避免样式冲突的效果

CSS 预处理器

指的是一种增强和拓展 CSS 编写能力的工具,比如 Less、Sass、Stylus

主要特性:

  • 变量
  • 嵌套
  • 混合
  • 函数

其中的变量和嵌套是实现样式隔离的主要手段

Vue Scoped

给 vue 单文件组件的 style 标签加上 scoped 属性,其组件内的 css 就只会影响当前组件

实践

假设我给远程应用某个元素加上 title 类名,这种常见的命名方式,加点样式,比如文字颜色

远程应用

<style>
.title {
  color: red;
}
</style>

本地应用也定义一个 title 类名,但不加到任何元素上

<style>
.title {
  color: blue;
}
</style>

你猜一下,现在加上了 title 类名的元素现在是什么颜色?答案是蓝色,因为通过检查元素你会发现两个应用定义的样式都以 style 的形式插入到文档的 head 中,导致样式全局生效,按照 css 的覆盖规则自然是相同类名相同属性,后一个覆盖前一个

现在让我们来修正一下,首先是最简单的 vue scoped 方案,在远程应用对应组件中的 style 标签加上 scoped 属性

<style scoped>
.title {
  color: red;
}
</style>

刷新一下页面,确实有效,检查一下元素会发现远程应用中组件内的元素多了以 data-v- 为开头的属性,其对应匹配的样式规则变成了 .title[data-v-cc6c4be9],相比纯 .title,这样的写法自然权重更高

但如果我在本地应用的 title 类名中加上 !important 呢?显然这种权重最高,还是把远程应用的样式覆盖了,所以只有 vue scoped 方案还不够

现在我们结合 BEM 的思想,给 .title 加上一层作用域 remote-app

<style scoped>
.remote-app-title {
  color: red;
}
</style>

没有问题,虽然可能有极小概率会产生冲突,但基本满足样式隔离的需求,就是有个问题,可读性和维护性一般,每个类名都要加上这样一层类名简直是地狱,这时候就需要 CSS 预处理器登场了,我这里选择用 less 演示,反正大差不差

由于 vite 开箱支持预处理器,我们只要在远程应用中安装 less 即可

pnpm add less --save-dev --filter remote
<style scoped lang="less">
.remote-app {
  &-title {
    color: red;
  }
}
</style>

& 表示当前的父选择器,即 .remote-app,通过嵌套形式用 -title 拼接在一起,最终还是编译成 .remote-app-title,后续组件内所有类都可以这样嵌套在里面,减少了冗余代码的编写,可读性也变强了

到这里也满足了样式隔离的基本需求

跨技术栈

假设本地应用是一个 react 应用,要加载远程应用暴露的 vue 组件,怎么办?连同实例一起加载吗?但其实 vue 官方提供了一种方案叫 https://cn.vuejs.org/guide/extras/web-components

Web Components

浏览器支持使用原生的 API 实现组件化,脱离了框架,如果有手段可以将框架定义的组件转为 web component 就能满足了跨技术栈的要求

同时又因为 shadow dom 的机制,看不到 web component 内部细节,无法选择其子元素,外部样式也无法影响内部样式,但可以在开发者工具中设置 Show user agent shadow DOM 选项后显示出来,比如浏览器原生控件 <input type="range">,通过这种方式也实现了样式隔离

实战

首先远程应用暴露一个 vue 组件

<template>
  <div>test</div>
</template>
// ...
export default defineConfig({
  plugins: [
	// ...
    federation({
	  // ...
      exposes: {
		// ...
        './Test': './src/components/TestComponent.vue' 
      },
      // ...
    })
  ],
  // ...
})

本地应用动态加载该组件(为了图方便,这里本地应用还是 vue 应用,如果是 react 应用也是大同小异)

<script setup lang="ts">
import { defineAsyncComponent, defineCustomElement } from 'vue'
 
const Test = defineAsyncComponent(() => import('remote/Test'))
const customElement = defineCustomElement(Test)
customElements.define('test-component', customElement)
</script>
 
<template>
  <test-component></test-component>
</template>

显示没有问题,就是出现了一个警告

[Vue warn]: Failed to resolve component: test-component If this is a native custom element, make sure to exclude it from component resolution via compilerOptions.isCustomElement.

这个警告 vue 文档也写了,要使用 web compoennt 自定义元素,要配置一下 vite,否则不知道这是元素还是组件

// ...
export default defineConfig({
  plugins: [
    vue({ 
      template: { 
        compilerOptions: {
          isCustomElement: (tag) => tag.includes('-')
        }
      }
    }),
	// ...
  ],
  // ...
})

现在看看样式隔离的效果, 和前面一样,加上个 title 类名,定义一个文字颜色

<template>
  <div class="title">test</div>
</template>
 
<style lang="less">
.title {
  color: yellow;
}
</style>

刷新一下页面,很强呀,完美隔离了本地应用的样式,但是组件本身定义的样式也没生效呀?

从官网得知,SFC 组件本身定义的样式在编译后被抽离成一个 css 文件,但没有注入到 shadow dom,因此 vue 官方提供解决方案是把 SFC 文件后缀改为 .ce.vue

// ...
export default defineConfig({
  plugins: [
	// ...
    federation({
	  // ...
      exposes: {
        // ...
        './Test': './src/components/TestComponent.ce.vue' // [!code focus]
      },
	  // ...
    })
  ],
  // ...
})

如果开发者工具已经开启了 Show user agent shadow DOM 选项,检查元素你就会看到,组件本身定义的样式出现 shadow dom 中

虽然我使用了微前端,但不代表我对跨技术栈情有独钟,相反,我更推荐使用团队间使用相同技术栈,能减少许多沟通成本和学习成本,但至少证明了跨技术栈的可能性

源码

这里