Skip to content

微前端核心原理:手搓一个远程组件系统

一、核心概念:微前端与远程组件

微前端的核心思想与远程组件 (Remote Components) 的概念等价。

  • 场景: 公司内有多个独立的应用(网站A、网站B),这些应用需要共享同一个组件,并且当该组件更新时,所有使用它的应用都应同步更新。

1.1 传统组件共享方案的弊端

a. 方案一:复制粘贴

  • 做法: 将A应用的组件代码直接拷贝到B应用。
  • 问题: 维护噩梦。当组件需要更新时,必须手动同步更新所有引用该组件的项目,非常繁琐且容易出错。

b. 方案二:发布为 NPM 包

  • 做法: 将通用组件发布到NPM,各个项目通过NPM安装使用。
  • 问题:
    1. 版本管理复杂: 组件更新后需要重新发布NPM包,所有依赖该包的项目都需要手动更新依赖版本并重新部署,流程繁琐。
    2. 依赖臃肿: 如果有大量通用组件,每个都发布成一个NPM包,会导致项目 package.json 中依赖数量激增,管理困难。

1.2 最终方案:远程组件

  • 做法:
    1. 将通用组件的代码独立打包成JS文件。
    2. 将打包好的JS文件部署到一个线上的静态资源服务器(如CDN)。
    3. 各个应用通过网络地址(URL)远程引入并执行这个JS文件,从而在页面上渲染出该组件。
  • 优势:
    • 无需NPM版本管理: 更新组件只需重新打包并替换服务器上的JS文件,所有引用方自动更新,无需更改项目代码和重新部署。
    • 管理简单: 避免了大量的NPM包依赖。
  • 结论: 远程组件方案是实现微前端的基石。理解了远程组件,就理解了微前端的原理。

二、远程组件实现原理

2.1 两个前置知识点

  1. 万物皆JS: 我们编写的 .vue (Vue) 或 .jsx (React) 文件,最终都会被编译成浏览器可执行的 JavaScript 文件。页面的渲染本质上是执行这些编译后的JS代码。
  2. import 的能力: import 语句不仅可以引入本地 node_modules 中的模块,也可以直接引入一个网络URL上的JS文件。

2.2 实现思路

  1. 独立开发: 将通用组件作为一个独立的项目进行开发。
  2. 打包: 将这个组件项目打包成一个或多个JS文件。
  3. 部署: 将打包产物上传到静态资源服务器(如CDN)。
  4. 远程加载: 主应用通过 import('http://your-cdn.com/remote-component.js') 的方式,异步加载远程组件的JS。
  5. 执行渲染: 执行加载到的JS代码,从而在主应用中渲染出组件。

三、实践:从0到1手搓远程组件

我们将构建三个部分:

  1. server: 一个简单的Node.js服务器,用于托管远程组件的静态资源。
  2. remote-components: 远程组件项目(子应用)。
  3. host-app: 加载远程组件的主应用。

3.1 步骤一:搭建静态资源服务器

使用 Node.js 和 express 快速创建一个静态服务器,它将 dist 目录作为静态资源根目录。

javascript
// server.js
const express = require('express');
const cors = require('cors'); // 引入cors解决跨域问题
const path = require('path');

const app = express();

// 核心:设置CORS中间件,允许所有跨域请求
app.use(cors());

// 核心:将./dist目录设置为静态资源目录
app.use(express.static(path.join(__dirname, 'dist')));

app.listen(8000, () => {
    console.log('Server is running on port 8000');
    console.log('Static directory is set to ./dist');
    console.log('Access remote assets via http://localhost:8000/your-asset-name.js');
});
  • 启动: node server.js
  • 作用: 启动后,可以将打包好的文件放入 dist 文件夹,并通过 http://localhost:8000/文件名 访问。

3.2 步骤二:进化1.0 - 粗糙的实现 (eval)

a. 创建远程组件 (子应用)看看新克隆的仓库目录结构:

.

  • 使用 npm create vite@latest 创建一个 Vue3 + Vite 项目。
  • 修改 main.js,将其挂载到一个特殊的ID上,以避免与主应用冲突。
javascript
// remote-components/src/main.js
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'

// 不挂载到 #app,避免与主应用冲突
createApp(App).mount('#remote')
vue
<template>
  <h1>我是一个远程应用</h1>
  <p>更新测试</p>
</template>
  • 打包: 在 remote-components 目录下运行 npm run build
  • 部署: 将 remote-components/dist/assets 目录下的JS和CSS文件拷贝到 server/dist 目录下。

b. 主应用加载

  • 在主应用 App.vue 中准备一个 div 作为远程组件的容器。
  • 使用 fetch 获取远程JS文件的文本内容,然后用 eval() 执行。
vue
<script setup>
import { onMounted } from 'vue';

onMounted(async () => {
  try {
    // 1. 在主应用中准备一个容器
    // <div id="remote"></div>

    // 2. 异步加载远程JS
    // 注意:文件名在每次打包后可能会变,这里先写死
    const response = await fetch('http://localhost:8000/index-D8xT4f1C.js'); 
    const remoteScript = await response.text();

    // 3. 使用 eval 执行JS代码 (不推荐)
    eval(remoteScript);

  } catch (error) {
    console.error('加载远程组件失败:', error);
  }
});
</script>

<template>
  <div>
    <h2>主应用自己的内容</h2>
    <hr>
    <div id="remote"></div>
  </div>
</template>
  • 问题分析:
    1. 跨域(CORS): JS请求不同源的JS文件会触发跨域策略。已在server.js中通过cors中间件解决。
    2. eval 不优雅且有安全风险: 将字符串作为代码执行,性能差,易受攻击。
    3. 不是真正的"组件": 加载的是一个完整的Vue应用实例(createApp),而不是一个可复用、可通信的组件。无法向其传递 props 或监听 events
    4. 硬编码文件名: 每次远程组件打包后,文件名中的hash会变,主应用需要手动更新URL,非常不便。

3.3 步骤三:进化2.0 - 库模式 (Library Mode)

目标:将远程组件打包成一个真正的“库”,主应用可以像使用普通组件一样使用它。

a. 改造远程组件 (子应用)

  1. 配置Vite库模式: 修改 vite.config.js,将其打包行为从“应用模式”切换到“库模式”。

    javascript
    // remote-components/vite.config.js
    import { defineConfig } from 'vite';
    import vue from '@vitejs/plugin-vue';
    import importToConst from 'rollup-plugin-import-to-const'; // 引入插件
    
    export default defineConfig({
      plugins: [
        vue(),
        // 使用插件将 import Vue from 'vue' 转换为 const ...
        importToConst({
          modules: ['vue']
        })
      ],
      build: {
        // 核心:开启库模式
        lib: {
          entry: 'src/main.js', // 入口文件
          name: 'RemoteComponents', // 库在UMD/IIFE模式下的全局变量名
          fileName: 'remote-components', // 打包后的文件名
          formats: ['es', 'umd'] // 输出格式
        },
        // 核心:将Vue从打包产物中排除
        rollupOptions: {
          external: ['vue'],
          output: {
            // 在UMD模式下,全局变量Vue就是'vue'
            globals: {
              vue: 'Vue'
            }
          }
        }
      }
    });
    • build.lib: 核心配置,将项目按库打包。
    • rollupOptions.external: 将vue排除掉。因为主应用已经有Vue了,无需重复打包,减小体积。
    • rollup-plugin-import-to-const: 一个关键插件。由于vueexternal了,打包后的代码会包含import { ... } from 'vue'。在浏览器中,这个'vue'无法被解析。此插件会将其转换为 const { ... } = window.Vue,从主应用暴露的全局Vue对象中获取方法。
  2. 修改组件入口: main.js不再是创建应用,而是导出所有需要暴露的组件。

    javascript
    // remote-components/src/main.js
    import Remote1 from './components/Remote1.vue';
    import Remote2 from './components/Remote2.vue';
    
    // 导出一个对象,包含所有远程组件
    export default {
      Remote1,
      Remote2
    };
  3. 创建真实组件: 接收 props

    vue
    <script setup>
    defineProps({
      message: String
    });
    </script>
    <template>
      <div class="div1">
        我是远程组件1 -- Props: {{ message }}
      </div>
    </template>
    <style>
    .div1 {
      color: red;
    }
    </style>

b. 改造主应用 (Host App)

  1. 暴露全局Vue: 在主应用的入口文件 (main.js) 中,将Vue挂载到window对象上,供远程组件使用。

    javascript
    // host-app/src/main.js
    import { createApp } from 'vue'
    import App from './App.vue'
    import * as Vue from 'vue'; // 引入整个Vue库
    
    // 核心:将Vue挂载到全局
    window.Vue = Vue;
    
    createApp(App).mount('#app')
  2. 动态加载并渲染组件: 在 App.vue 中使用Vue的动态组件 <component>

    vue
    <script setup>
    import { ref, onMounted, shallowRef } from 'vue';
    
    // 使用 shallowRef 存储组件定义,避免不必要的深度侦听
    const remoteComp = shallowRef(null);
    const message = ref('Hello from Host!');
    
    onMounted(async () => {
      // 加载JS模块
      const remoteModule = await import('http://localhost:8000/remote-components.js');
      remoteComp.value = remoteModule.default.Remote1; // 获取导出的组件
    
      // 动态加载CSS
      loadCss('http://localhost:8000/style.css');
      
      // 测试props响应式更新
      setTimeout(() => {
        message.value = "Updated message!";
      }, 2000);
    });
    
    function loadCss(url) {
      const link = document.createElement('link');
      link.rel = 'stylesheet';
      link.href = url;
      document.head.appendChild(link);
    }
    </script>
    
    <template>
      <h2>主应用</h2>
      <hr>
      <component v-if="remoteComp" :is="remoteComp" :message="message" />
    </template>
  • 结果: 实现了真正的组件化加载。可以传递props,可以控制更新,代码更优雅、可维护性更高。

3.4 步骤四:进化3.0 - 生产级方案

在实际工作中,组件文件名和路径不应硬编码,而是通过一个接口动态获取。

  • 流程:

    1. 远程组件项目每次构建后,将产物文件名(JS、CSS)及组件映射关系上传到一个配置中心或生成一个manifest.json文件。
    2. 主应用在加载远程组件前,先请求一个接口(或manifest.json),获取当前可用的远程组件列表及其最新的资源URL。
    3. 根据获取到的URL动态加载组件。
  • 接口返回的数据结构示例:

    json
    {
      "Remote1": {
        "js": "http://cdn.com/remote-components.a1b2c3d4.js",
        "css": "http://cdn.com/style.e5f6g7h8.css"
      },
      "Remote2": {
        "js": "http://cdn.com/remote-components.a1b2c3d4.js",
        "css": "http://cdn.com/style.e5f6g7h8.css"
      }
    }

四、总结

4.1 远程组件方案优势

  1. 无感更新: 组件更新后,只需替换服务器上的资源文件,所有引用方无需发版即可享受到更新。
  2. 依赖清晰: 避免NPM依赖爆炸,项目结构更清晰。
  3. 独立开发与部署: 组件可以独立于主应用进行开发、测试和部署,降低了团队间的耦合度。

4.2 打包远程组件的四个要点

  1. JS为入口: 打包配置的入口必须是JS文件(如main.js),而不是HTML。
  2. 库模式打包: 必须使用构建工具的库模式(如Vite的build.lib)进行打包。
  3. 抽离公共依赖: 必须将vuereact等主应用已存在的公共库从打包产物中抽离(external),避免重复加载和版本冲突。
  4. 全局注入依赖: 由于公共依赖被抽离,需要主应用将这些库(如Vue)注入到全局作用域(如window),以便远程组件能找到它们。

4.3 进一步探索

  1. Webpack Module Federation: Webpack 5 推出的“模块联邦”功能,为微前端提供了官方、更强大的解决方案,值得深入研究。
  2. 自动化脚本: 编写脚本实现“打包远程组件 -> 自动上传到服务器 -> 更新manifest.json”的自动化流程。