vue组件之远程组件
手里有一个多团队共同研发的项目,A团队需要提供组件给B团队,因为组件更新频率太快,且两个团队又各自有自己的发版频率,最终导致两个团队都不堪其扰。于是我想到了远程组件,从线上实时加载组件,这样就不需要A团队的组件每次更新都去麻烦B团队,毕竟就算有CI/CD每一次打包都挺麻烦的。
异步组件-defineAsyncComponent
在看官方文档的时候,在异步组件基本用法里出现以下内容:

从服务器获取组件......
那就先试试
- 将组件放置到服务器,获取到可访问的URL地址。
先简单写一个自增组件:
html
// 远程组件
<template>
<div class="child-component">
<span @click="increase">自增+1</span> <span>{{count}}</span>
</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
const increase = () => {
count.value ++
}
</script>
这里我使用http-server
配置服务器,步骤比较简单:
安装npm包
cmd
npm i -g http-server
找到远程组件的文件夹,执行命令。
cmd
http-server -p 8080 ./ --cors
这里我通过-p
设置了端口号8080
,设置服务器根目录为当前文件夹./
,最后通过-cors
设置允许跨域,不然会无法访问。
最终得到远程组件的地址:http://localhost:8080/remoteComponent.vue
- 通过
defineAsyncComponent
方法,获取到组件代码,编译成组件进行使用。
代码如下:
html
// 主应用
<template>
<h1>Hello World</h1>
<remoteComponent />
</template>
<script setup>
import { defineAsyncComponent } from 'vue'
const remoteComponent = defineAsyncComponent(() => {
return new Promise((resolve) => {
resolve(import ('http://localhost:8080/remoteComponent.vue'))
})
})
</script>
太简单了,结果:

bash
Failed to load module script: Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of "application/octet-stream". Strict MIME type checking is enforced for module scripts per HTML spec.
Uncaught (in promise) TypeError: Failed to fetch dynamically imported module: http://localhost:8080/remoteComponent.vue
以上两个错误表示,无法加载非JavaScript
和html
文件格式以外的文件,即不能加载vue
文件,加载异步模块失败。
既然如此,改一改代码
html
// 主应用
<template>
<h1>Hello World</h1>
<remoteComponent />
</template>
<script setup>
import { defineAsyncComponent } from 'vue'
const remoteComponent = defineAsyncComponent(() => {
return new Promise((resolve) => {
fetch('http://localhost:8080/remoteComponent.vue')
.then(res => {
resolve(res.text())
})
})
})
</script>

又报错
bash
Unhandled error during execution of async component loader
异步组件加载过程中出现错误。但是远程组件的代码已经加载出来了,恰好也是这里出了问题。因为加载的仅仅是代码,或者说是一串字符串而已,而并不是一个组件。
思路打开,解决方案有俩个,一个是先打包再加载,另一个是先加载再打包成组件。
先打包再加载
将远程组件打包成js模块(umd)
js
// 远程组件
export default {
template: `
<div class="child-component">
<span @click="increase">自增+1</span>
<span>{{ count }}</span>
</div>
`,
data() {
return {
count: 0
};
},
methods: {
increase() {
this.count++;
}
}
};
html
// 主应用
<template>
<h1>Hello World</h1>
<component v-if="remoteComponent" :is="remoteComponent" />
</template>
<script setup>
import { defineAsyncComponent, onMounted, ref } from 'vue'
const remoteComponent = ref(null)
onMounted(async() => {
const module = await import('http://localhost:8080/remoteComponent.js')
remoteComponent.value = defineAsyncComponent(() => Promise.resolve(module.default))
})
</script>

还是两个报错,解读一下:
- 组件被错误地设置为响应式,这里可以使用
markRaw
解除响应式 - 这是 Vue 运行时构建版本不支持模板编译 导致的错误,可以配置 Vite 别名使用带编译器的 Vue 构建版本。 这里只需要再对主应用进行两处微调:
html
// 主应用
<template>
<h1>Hello World</h1>
<component v-if="remoteComponent" :is="remoteComponent" /> // 需要等待模块加载完成,所以使用v-if
</template>
<script setup>
import { defineAsyncComponent, onMounted, ref, markRaw } from 'vue'
const remoteComponent = ref(null)
onMounted(async() => {
const module = await import('http://localhost:8080/remoteComponent.js') // 异步加载组件地址
const comp = defineAsyncComponent(() => Promise.resolve(module.default)) // 定义异步组件
remoteComponent.value = markRaw(comp) // 解除响应式
})
</script>
js
// 主应用 vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
vue: 'vue/dist/vue.esm-bundler.js' // Vue 运行时构建版本不支持模板编译,配置 Vite 别名使用带编译器的 Vue 构建版本
}
}
})
最终结果:

先加载再打包
首先看了一下vue.complie()
,如果是作为主应用来说,使用起来太麻烦了。拿到源码之后,需要先对源码进行切割,分成template
、script
、style
,再用vue解析器分别解析和插入。另外script
部分还需要判断是否为setup
,再进行改写成普通script
。最后才是组装成组件,过程麻烦且繁杂。
不过,有一个插件vue3-sfc-loader
,可以将以上过程简单处理。
html
<template>
<h1>Hello World</h1>
<remoteComponent />
</template>
<script setup>
import * as Vue from 'vue'
import { loadModule } from 'vue3-sfc-loader'
import { defineAsyncComponent } from 'vue'
// loadModule 配置项
const options = {
moduleCache: { // 缓存模块
vue: Vue
},
// 获取文件内容的方法,接收一个 URL,返回该 URL 的文本内容
async getFile(url) {
const response = await fetch(url)
if (response.status === 200) {
return response.text()
} else {
throw new Error(response.statusText)
}
},
// 将组件中提取的样式插入到页面 head 中
addStyle(textContent) {
const style = Object.assign(document.createElement("style"), { textContent })
const refs = document.head.getElementsByTagName("style")[0] || null
document.head.insertBefore(style, refs)
}
}
// 使用 defineAsyncComponent 创建一个异步组件
const remoteComponent = defineAsyncComponent(async() => {
return new Promise((resolve) => {
// 使用 vue3-sfc-loader 加载远程 .vue 文件
loadModule('http://localhost:8080/remoteComponent.vue', options).then((res) => {
resolve(res)
})
})
})
</script>
模块联邦-vite-plugin-federation
以上,不管是先加载还是先打包(umd),其实都只是简单场景下的单个文件组件。真实场景其实可能是工程级别的大型组件,甚至引入很多第三方组件。所以就不太适用。
恰好在之前的微前端技术选型时,就了解过模块联邦
。
模块联邦(Module Federation)是Webpack 5引入的一项革命性功能,允许不同JavaScript应用在运行时动态共享代码和依赖,是实现微前端架构的核心技术之一。
其基本架构为:
- Host(主应用):作为主容器,动态加载远程模块的应用。
- Remote(远程应用):提供可共享模块的独立应用,通过暴露接口供Host消费。
- 双向主机(Bidirectional-hosts):兼具Host和Remote角色的应用,实现模块互调。
在vite中需要实现这个功能,需要借助vite-plugin-federation。
先起一个正常的npm publish
的组件,具体代码就不展示了。
再安装vite-plugin-federation
和vite-plugin-top-level-await
。
bash
npm i @originjs/vite-plugin-federation -D
npm i vite-plugin-top-level-await -S
js
// vite.config.js
// 正常组件引入
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path, {resolve } from 'path'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import vueJsx from '@vitejs/plugin-vue-jsx'
// 模块联邦引入
import federation from "@originjs/vite-plugin-federation" // 模块联邦
import topLevelAwait from 'vite-plugin-top-level-await' // 顶层支持引入await
export default defineConfig({
plugins: [
vue(),
vueJsx(),
AutoImport({
imports: ['vue', 'vue-router'],
resolvers: [ElementPlusResolver()]
}),
Components({ resolvers: [ElementPlusResolver()] }),
// 模块联邦
federation({
name: 'remote_app', // 模块名称
filename: 'remote-app.js', // 打包后主组件名
exposes: {
'./RemoteApp': './src/components/RemoteApp.vue', // **导出包名**:导出包主入口
},
shared: ['vue', 'element-plus', 'axios', 'vue-router', 'dayjs', 'echarts'] // 共享第三方库
}),
topLevelAwait({
promiseExportName: "__tla",
promiseImportName: i => `__tla_${i}`
})
],
resolve: {
alias: [
{
find: '@',
replacement: resolve(__dirname, 'src')
}
]
},
// 为了提高远程请求组件请求速度,可以在打包配置里做一些处理
build: {
assetsInlineLimit: 40960, // 小于 40KB 的静态资源将被内联为 Base64,减少请求次数
minify: true, // 开启 JS/CSS 压缩,减小体积
cssCodeSplit: false, // 关闭 CSS 分割,CSS 内联到 HTML,减少请求次数
sourcemap: false, // 不生成源码映射文件,减少包体积
rollupOptions: {
output: {
minifyInternalExports: false // 保留内部模块导出变量名,便于调试(后期可关闭)
}
}
}
})
正常build之后产生的文件如下:

直接将整个dist文件丢到服务器,并配置好端口允许跨域让主应用可以访问remote-app.js
。这里做演示,直接还是http-server
启动,得到http://localhost:8080/assets/remote-app.js
。
然后开始对主应用进行改造。
首先需要对远程应用的share
配置中的共享包进行安装,如'vue', 'element-plus', 'axios', 'vue-router', 'dayjs', 'echarts'
。其次,安装@originjs/vite-plugin-federation
。这里就不展开了。
看vite.config.js
的配置:
js
// vite.config.js
// 正常引入
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
// 模块联邦引入
import federation from "@originjs/vite-plugin-federation"
export default defineConfig(({mode}) =>{
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [
vue(),
federation({
name: 'host-app', // 主应用名称
filename: 'remote-app.js',
remotes: {
remote_app: { // **模块名称**
// 请求远程组件主入口,这里使用了环境变量,主要是为了开发和生成模式的切换方便
external: `Promise.resolve('${env.VITE_API_BASE_URL}/assets/remote-app.js')`,
// 使用异步加载方式,防止请求时间过长,这也是为什么远程引用需要用vite-plugin-top-level-await的原因
externalType: "promise"
}
},
// 远程组件使用的第三方包,需要原原本本在这里再复制一份,不需要其他处理。这里只是简单配置,也可以查看官方文档进行深入优化。
shared: ['vue', 'element-plus', 'axios', 'vue-router', 'dayjs', 'echarts']
})
],}
})
页面使用时,只需要简单引入即可:
html
<template>
<h1>Hello World</h1>
<RemoteApp></RemoteApp>
</template>
<script setup>
// 正常通过import引入,注意
// RemoteApp是对应远程组件里配置的federation=》exposes里的导出包名
// remote_app是主应用里federation=》remotes配置的模块名称
import RemoteApp from 'remote_app/RemoteApp'
</script>
一般到这里就差不多了,不过需要注意的还有一些地方。如静态资源,比如第三方引入一张比较大的图片(小的图片通过vite.config.js里的build.assetsInlineLimit:40960
配置将小于40kb的处理成了base64)会因为域的问题,导致图片无法加载。

这里其实有几种处理方式,随便说几种实用的。
- 图片少的,直接在代码里改。
html
<template>
<img :src="imgUrl" />
</template>
<script setup>
const imgUrl = new URL('@/assets/images/test.png', import.meta.url).href
</script>
// or
<script setup>
import { computed } from 'vue'
const baseUrl = import.meta.env.VITE_API_BASE_URL
const imgUrl = computed(() => `${baseUrl}assets/images/test.png`)
</script>
- 图片多的,上base。
js
// vite.config.js
export default defineConfig({
base: 'https://yourdomain.com/'
})