微前端核心原理:手搓一个远程组件系统
一、核心概念:微前端与远程组件
微前端的核心思想与远程组件 (Remote Components) 的概念等价。
- 场景: 公司内有多个独立的应用(网站A、网站B),这些应用需要共享同一个组件,并且当该组件更新时,所有使用它的应用都应同步更新。
1.1 传统组件共享方案的弊端
a. 方案一:复制粘贴
- 做法: 将A应用的组件代码直接拷贝到B应用。
- 问题: 维护噩梦。当组件需要更新时,必须手动同步更新所有引用该组件的项目,非常繁琐且容易出错。
b. 方案二:发布为 NPM 包
- 做法: 将通用组件发布到NPM,各个项目通过NPM安装使用。
- 问题:
- 版本管理复杂: 组件更新后需要重新发布NPM包,所有依赖该包的项目都需要手动更新依赖版本并重新部署,流程繁琐。
- 依赖臃肿: 如果有大量通用组件,每个都发布成一个NPM包,会导致项目
package.json中依赖数量激增,管理困难。
1.2 最终方案:远程组件
- 做法:
- 将通用组件的代码独立打包成JS文件。
- 将打包好的JS文件部署到一个线上的静态资源服务器(如CDN)。
- 各个应用通过网络地址(URL)远程引入并执行这个JS文件,从而在页面上渲染出该组件。
- 优势:
- 无需NPM版本管理: 更新组件只需重新打包并替换服务器上的JS文件,所有引用方自动更新,无需更改项目代码和重新部署。
- 管理简单: 避免了大量的NPM包依赖。
- 结论: 远程组件方案是实现微前端的基石。理解了远程组件,就理解了微前端的原理。
二、远程组件实现原理
2.1 两个前置知识点
- 万物皆JS: 我们编写的
.vue(Vue) 或.jsx(React) 文件,最终都会被编译成浏览器可执行的 JavaScript 文件。页面的渲染本质上是执行这些编译后的JS代码。 import的能力:import语句不仅可以引入本地node_modules中的模块,也可以直接引入一个网络URL上的JS文件。
2.2 实现思路
- 独立开发: 将通用组件作为一个独立的项目进行开发。
- 打包: 将这个组件项目打包成一个或多个JS文件。
- 部署: 将打包产物上传到静态资源服务器(如CDN)。
- 远程加载: 主应用通过
import('http://your-cdn.com/remote-component.js')的方式,异步加载远程组件的JS。 - 执行渲染: 执行加载到的JS代码,从而在主应用中渲染出组件。
三、实践:从0到1手搓远程组件
我们将构建三个部分:
server: 一个简单的Node.js服务器,用于托管远程组件的静态资源。remote-components: 远程组件项目(子应用)。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>- 问题分析:
- 跨域(CORS): JS请求不同源的JS文件会触发跨域策略。已在
server.js中通过cors中间件解决。 eval不优雅且有安全风险: 将字符串作为代码执行,性能差,易受攻击。- 不是真正的"组件": 加载的是一个完整的Vue应用实例(
createApp),而不是一个可复用、可通信的组件。无法向其传递props或监听events。 - 硬编码文件名: 每次远程组件打包后,文件名中的hash会变,主应用需要手动更新URL,非常不便。
- 跨域(CORS): JS请求不同源的JS文件会触发跨域策略。已在
3.3 步骤三:进化2.0 - 库模式 (Library Mode)
目标:将远程组件打包成一个真正的“库”,主应用可以像使用普通组件一样使用它。
a. 改造远程组件 (子应用)
配置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: 一个关键插件。由于vue被external了,打包后的代码会包含import { ... } from 'vue'。在浏览器中,这个'vue'无法被解析。此插件会将其转换为const { ... } = window.Vue,从主应用暴露的全局Vue对象中获取方法。
修改组件入口:
main.js不再是创建应用,而是导出所有需要暴露的组件。javascript// remote-components/src/main.js import Remote1 from './components/Remote1.vue'; import Remote2 from './components/Remote2.vue'; // 导出一个对象,包含所有远程组件 export default { Remote1, Remote2 };创建真实组件: 接收
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)
暴露全局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')动态加载并渲染组件: 在
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 - 生产级方案
在实际工作中,组件文件名和路径不应硬编码,而是通过一个接口动态获取。
流程:
- 远程组件项目每次构建后,将产物文件名(JS、CSS)及组件映射关系上传到一个配置中心或生成一个
manifest.json文件。 - 主应用在加载远程组件前,先请求一个接口(或
manifest.json),获取当前可用的远程组件列表及其最新的资源URL。 - 根据获取到的URL动态加载组件。
- 远程组件项目每次构建后,将产物文件名(JS、CSS)及组件映射关系上传到一个配置中心或生成一个
接口返回的数据结构示例:
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 远程组件方案优势
- 无感更新: 组件更新后,只需替换服务器上的资源文件,所有引用方无需发版即可享受到更新。
- 依赖清晰: 避免NPM依赖爆炸,项目结构更清晰。
- 独立开发与部署: 组件可以独立于主应用进行开发、测试和部署,降低了团队间的耦合度。
4.2 打包远程组件的四个要点
- JS为入口: 打包配置的入口必须是JS文件(如
main.js),而不是HTML。 - 库模式打包: 必须使用构建工具的库模式(如Vite的
build.lib)进行打包。 - 抽离公共依赖: 必须将
vue、react等主应用已存在的公共库从打包产物中抽离(external),避免重复加载和版本冲突。 - 全局注入依赖: 由于公共依赖被抽离,需要主应用将这些库(如
Vue)注入到全局作用域(如window),以便远程组件能找到它们。
4.3 进一步探索
- Webpack Module Federation: Webpack 5 推出的“模块联邦”功能,为微前端提供了官方、更强大的解决方案,值得深入研究。
- 自动化脚本: 编写脚本实现“打包远程组件 -> 自动上传到服务器 -> 更新
manifest.json”的自动化流程。