vue组件之远程组件

1,178 阅读8分钟

vue组件之远程组件

手里有一个多团队共同研发的项目,A团队需要提供组件给B团队,因为组件更新频率太快,且两个团队又各自有自己的发版频率,最终导致两个团队都不堪其扰。于是我想到了远程组件,从线上实时加载组件,这样就不需要A团队的组件每次更新都去麻烦B团队,毕竟就算有CI/CD每一次打包都挺麻烦的。

异步组件-defineAsyncComponent

在看官方文档的时候,在异步组件基本用法里出现以下内容:

image.png

从服务器获取组件......

那就先试试

  1. 将组件放置到服务器,获取到可访问的URL地址。

先简单写一个自增组件:

// 远程组件
<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包

npm i -g http-server

找到远程组件的文件夹,执行命令。

http-server -p 8080 ./ --cors

这里我通过-p设置了端口号8080,设置服务器根目录为当前文件夹./,最后通过-cors设置允许跨域,不然会无法访问。

最终得到远程组件的地址:http://localhost:8080/remoteComponent.vue

  1. 通过defineAsyncComponent方法,获取到组件代码,编译成组件进行使用。

代码如下:

// 主应用
<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>

太简单了,结果:

image.png

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

以上两个错误表示,无法加载非JavaScripthtml文件格式以外的文件,即不能加载vue文件,加载异步模块失败。

既然如此,改一改代码

// 主应用
<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>

image.png

又报错

Unhandled error during execution of async component loader 

异步组件加载过程中出现错误。但是远程组件的代码已经加载出来了,恰好也是这里出了问题。因为加载的仅仅是代码,或者说是一串字符串而已,而并不是一个组件。

思路打开,解决方案有俩个,一个是先打包再加载,另一个是先加载再打包成组件。

先打包再加载

将远程组件打包成js模块(umd)

// 远程组件
export default {
  template: `
    <div class="child-component">
      <span @click="increase">自增+1</span>
      <span>{{ count }}</span>
    </div>
  `,
  data() {
    return {
      count: 0
    };
  },
  methods: {
    increase() {
      this.count++;
    }
  }
};
// 主应用
<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>

image.png

还是两个报错,解读一下:

  1. 组件被错误地设置为响应式,这里可以使用markRaw解除响应式
  2. 这是 Vue 运行时构建版本不支持模板编译 导致的错误,可以配置 Vite 别名使用带编译器的 Vue 构建版本。 这里只需要再对主应用进行两处微调:
// 主应用
<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>
// 主应用 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 构建版本
    }
  }
})

最终结果:

image.png

先加载再打包

首先看了一下vue.complie(),如果是作为主应用来说,使用起来太麻烦了。拿到源码之后,需要先对源码进行切割,分成templatescriptstyle,再用vue解析器分别解析和插入。另外script部分还需要判断是否为setup,再进行改写成普通script。最后才是组装成组件,过程麻烦且繁杂。

不过,有一个插件vue3-sfc-loader,可以将以上过程简单处理。

<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-federationvite-plugin-top-level-await

npm i @originjs/vite-plugin-federation -D
npm i vite-plugin-top-level-await -S
// 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之后产生的文件如下:

image.png

直接将整个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的配置:

// 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']
    })
  ],}
})

页面使用时,只需要简单引入即可:

<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)会因为域的问题,导致图片无法加载。

image.png

这里其实有几种处理方式,随便说几种实用的。

  1. 图片少的,直接在代码里改。
<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>
  1. 图片多的,上base。
// vite.config.js
export default defineConfig({
  base: 'https://yourdomain.com/'
})

模块联邦-@module-federation/vite

在使用vite-plugin-federation后一段时间内,出现了几个bug。

  1. 相对路径引发的路径错误。这个问题在集成到微前端里会出现组件无法显示的问题,后续通过对远程组件打包后的代码进行路径全局替换的操作勉强可以运行。作者貌似已经修复,并更新代码,只是没发版本。

  2. 第三方组件引入出现问题,主要是element-plus的表单验证丢失了提示文字、全局配置丢失等。

所以,除了在找解决方案,也在寻找替换方案,甚至想自己造轮子了。

最后找到了另外一个vite版本的模块联邦插件——@module-federation/vite。对比两者之间的更新频率,瞬间感觉稳了。

@originjs/vite-plugin-federation

@module-federation/vite

两者之间的替换也非常丝滑,先安装@module-federation/vite,执行命令:

npm i @module-federation/vite

为了验证bug是否解决,重新修改远程组件,并改造远程组件的vite.config.js

// App.vue
<template>
  <el-config-provider :locale="config.locale">
    <el-button @click="counterIncrease">自增</el-button>
    <div>
      计数器1: {{ counter }}
    </div>
    <el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" label-width="80px" @keyup.enter="submit">
      <el-form-item label="用户名" prop="username">
        <el-input v-model="loginForm.username" placeholder="请输入用户名"></el-input>
      </el-form-item>
      <el-form-item label="密码" prop="password">
        <el-input v-model="loginForm.password" placeholder="请输入密码" type="password" show-password></el-input>
      </el-form-item>
      <el-form-item label="时间">
        <el-date-picker type="datetime" v-model="loginForm.time" placeholder="请选择时间" value-format="yyyy-MM-dd HH:mm:ss"></el-date-picker>
      </el-form-item>
      <el-form-item>
        <el-button class="submit-button" type="primary" @click="submit">登录</el-button>
      </el-form-item>
    </el-form>
  </el-config-provider>
</template>

<script setup>
import zhCn from 'element-plus/dist/locale/zh-cn.min.mjs'

const config = {
  locale: zhCn
}
const loginForm = reactive({
  username: '',
  password: '',
  time: ''
})
const loginRules = reactive({
  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
})

const submit = () => {
  loginFormRef.value.validate(valid => {
    if (valid) {
      console.log(loginForm)
    }
  })
}

const loginFormRef = ref()

const counter = ref(0);

const counterIncrease = () => {
  counter.value++;
}
</script>
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// 自动引入(测试自动引入是否会影响远程组件)
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

// 引入模块联邦
import { federation } from "@module-federation/vite"
import topLevelAwait from 'vite-plugin-top-level-await'

import { resolve } from 'path'

export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      imports: ['vue'],
      resolvers: [ElementPlusResolver()],
      eslintrc: {
        enabled: true
      }
    }),
    Components({
      resolvers: [ElementPlusResolver({ importStyle: 'sass' })]
    }),
    federation({
      name: 'remote_app', // 模块名称
      filename: 'remote-app.js', // 打包后主组件名
      exposes: {
        './RemoteApp': './src/App.vue',  // **导出包名**:导出包主入口
      },
      shared: ['vue', 'element-plus']  // 共享第三方库 
    }),
    topLevelAwait({
      promiseExportName: "__tla",
      promiseImportName: i => `__tla_${i}`
    })
  ],
  base: 'http://localhost:8080/', // 不加入base会导致路径丢失
  resolve: {
    alias: [
      {
        find: '@',
        replacement: resolve(__dirname, 'src')
      }
    ]
  },
  build: {
    assetsInlineLimit: 40960,
    minify: true,
    cssCodeSplit: false,
    sourcemap: true,
    rollupOptions: {
      output: {
        minifyInternalExports: false
      }
    }
  }
})

正常build之后,生成的文件跟vite-plugin-federation是有一些区别的。首先是目录结构:

image.png remote-app.js会直接生成到dist根目录,而不是之前的assets文件夹里。同时,在打包之后的index.html文件夹里也有一些不同,增加了几个模块联邦的js文件。

// index.html
// 这里一定要注意打包时需要加上base,配置好域名,不然会导致这几个js文件因为相对路径发生404的bug
<!doctype html>
<html lang="en">
  <head>
    <script type="module" src="http://localhost:8080/assets/hostInit-BNWC3vLo.js"></script>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="http://localhost:8080/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + Vue</title>
    <script type="module" crossorigin src="http://localhost:8080/assets/index-C1YYHz6A.js"></script>
    <link rel="modulepreload" crossorigin href="http://localhost:8080/assets/remote_mf_2_app__mf_v__runtimeInit__mf_v__-dZdgRTGy.js">
    <link rel="modulepreload" crossorigin href="http://localhost:8080/assets/shared.esm-bundler-Dd6jVM5c.js">
    <link rel="modulepreload" crossorigin href="http://localhost:8080/assets/App-Dtbgt7JE.js">
    <link rel="stylesheet" crossorigin href="http://localhost:8080/assets/style-P9wh5IMC.css">
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

远程端的改造基本就完成了,宿主端引入也比较简单:

// vite.config.js
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { federation } from "@module-federation/vite"

export default defineConfig(({mode}) =>{
  const env = loadEnv(mode, process.cwd(), '')
  return {
    plugins: [
    vue(),
    federation({
      name: 'host-app',
      filename: 'remoteEntry.js',
      remotes: {
        'remote_app': {
          type: 'module', // 默认是var,因为是vite,所以需要改成module,不然会报错
          name: 'remote_app', // 默认值是远程端的name,可以不填
          shareScope: 'default', // 默认值是default,可以不填
          entryGlobalName: 'remote_app', // 默认值是远程端的name,可以不填
          entry: `${env.VITE_API_LY_REMOTE_URL}/remote-app.js`, // 远程端地址,这里配置了环境变量,方便区别生产和开发
        }
      },
      shared: ['vue', 'element-plus']
    })
  ],
  }
})
<template>
  <h1>Hello World</h1>
  <RemoteApp></RemoteApp>
</template>

<script setup>
 // remote_app是 宿主端  federation.name     的名称
 // RemoteApp是  远程端  federation.exposes  的导出包名
import RemoteApp from 'remote_app/RemoteApp'
</script>

以上是@module-federation/vite关于模块联邦远程组件的玩法,感谢大佬。