JavaScript内核与高级编程之:`JavaScript`的`Micro Frontends`:从 `single-spa` 到 `Module Federation` 的演进。

各位靓仔靓女们,晚上好!我是你们的老朋友,今天咱们来聊聊一个前端界的热门话题——微前端(Micro Frontends)。这玩意儿听起来高大上,其实说白了就是把一个巨大的前端应用拆成一个个小的、可以独立开发、独立部署的“积木”,然后像搭乐高一样把它们拼起来。

今天咱们的重点是微前端的演进之路,从早期的 single-spa 到现在炙手可热的 Module Federation,看看它们是怎么一步步把前端开发带上更高境界的。

一、 为什么要搞微前端?

在深入技术细节之前,咱们先来聊聊动机。为啥要搞微前端?难道单体应用它不香吗?

其实,单体应用就像一个大胖子,刚开始的时候,身手还挺敏捷,但随着业务越来越复杂,功能越来越多,这个胖子就变得越来越臃肿,启动慢,部署慢,改动一点点东西都要重新部署整个应用,简直让人抓狂。而且,如果团队成员使用的技术栈不同,单体应用就会变成一个巨大的技术债务,难以维护。

微前端就是为了解决这些问题而生的。它可以带来以下好处:

  • 独立开发和部署: 每个微前端应用都可以由一个独立的团队负责开发和部署,互不干扰。
  • 技术栈无关: 每个微前端应用可以使用不同的技术栈,比如有的用 React,有的用 Vue,有的用 Angular,只要它们能最终渲染成 HTML,就都可以整合在一起。
  • 更好的可维护性: 由于每个微前端应用都比较小,代码量少,逻辑清晰,所以更容易维护。
  • 增量升级: 可以逐步升级应用,而不是一次性重写整个应用。
  • 更快的构建和部署: 每个微前端应用的构建和部署速度都更快,可以更快地响应业务需求。

二、 初出茅庐:single-spa

single-spa 可以说是微前端领域的先驱者之一。它的核心思想是:一个页面,多个框架。简单来说,就是在一个 HTML 页面中,可以同时运行多个前端框架的应用。

single-spa 通过一个叫做 single-spa-config.js 的配置文件来管理这些微前端应用。这个配置文件定义了每个微前端应用的入口、加载方式、激活条件等等。

2.1 核心概念

  • Root Config (根配置): 这是整个应用的入口,负责加载和引导所有的微前端应用。
  • Applications (应用): 指的是一个个独立的微前端应用,每个应用都有自己的生命周期函数 (bootstrap, mount, unmount)。
  • Activity Function (激活函数): 一个函数,用于判断当前路由是否应该激活某个微前端应用。

2.2 代码示例

首先,我们需要安装 single-spa:

npm install single-spa --save

然后,创建一个 single-spa-config.js 文件:

import * as singleSpa from 'single-spa';

// 定义一个微前端应用
const app1 = () => import('./app1/app1.js');
const app2 = () => import('./app2/app2.js');

// 注册微前端应用
singleSpa.registerApplication(
  'app1', // 应用名称
  app1, // 加载应用的函数
  location => location.pathname.startsWith('/app1'), // 激活函数
  { someData: 'app1 data' } // 传递给应用的 props
);

singleSpa.registerApplication(
  'app2',
  app2,
  location => location.pathname.startsWith('/app2'),
  { someData: 'app2 data' }
);

// 启动 single-spa
singleSpa.start();

在这个例子中,我们注册了两个微前端应用:app1app2。当 URL 的路径以 /app1 开头时,app1 应用会被激活;当 URL 的路径以 /app2 开头时,app2 应用会被激活。

接下来,我们需要创建 app1app2 两个微前端应用。这里以 React 应用为例:

app1/app1.js:

import React from 'react';
import ReactDOM from 'react-dom';

let reactLifecycles = null;

async function bootstrap(props) {
    console.log('app1 bootstrap', props);
    return Promise.resolve();
}

async function mount(props) {
    console.log('app1 mount', props);

    const App = () => (
        <div>
            <h1>App 1</h1>
            <p>Hello from App 1!  Data from root: {props.someData}</p>
        </div>
    );

    ReactDOM.render(<App />, document.getElementById('app1-container'));
    return Promise.resolve();
}

async function unmount(props) {
    console.log('app1 unmount', props);
    ReactDOM.unmountComponentAtNode(document.getElementById('app1-container'));
    return Promise.resolve();
}

reactLifecycles = {
    bootstrap,
    mount,
    unmount,
};

export const bootstrap = reactLifecycles.bootstrap;
export const mount = reactLifecycles.mount;
export const unmount = reactLifecycles.unmount;

export function render() {
    if (!document.getElementById('app1-container')) {
        const container = document.createElement("div");
        container.id = 'app1-container';
        document.body.appendChild(container);
    }

    mount({ someData: 'Initial data'});
}

// 如果不是 single-spa 环境,直接渲染
if (!window.singleSpaNavigate) {
    render();
}

app2/app2.js:

import React from 'react';
import ReactDOM from 'react-dom';

let reactLifecycles = null;

async function bootstrap(props) {
    console.log('app2 bootstrap', props);
    return Promise.resolve();
}

async function mount(props) {
    console.log('app2 mount', props);

    const App = () => (
        <div>
            <h1>App 2</h1>
            <p>Hello from App 2!  Data from root: {props.someData}</p>
        </div>
    );

    ReactDOM.render(<App />, document.getElementById('app2-container'));
    return Promise.resolve();
}

async function unmount(props) {
    console.log('app2 unmount', props);
    ReactDOM.unmountComponentAtNode(document.getElementById('app2-container'));
    return Promise.resolve();
}

reactLifecycles = {
    bootstrap,
    mount,
    unmount,
};

export const bootstrap = reactLifecycles.bootstrap;
export const mount = reactLifecycles.mount;
export const unmount = reactLifecycles.unmount;

export function render() {
    if (!document.getElementById('app2-container')) {
        const container = document.createElement("div");
        container.id = 'app2-container';
        document.body.appendChild(container);
    }
    mount({ someData: 'Initial data'});
}

// 如果不是 single-spa 环境,直接渲染
if (!window.singleSpaNavigate) {
    render();
}

最后,创建一个 index.html 文件:

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Single-SPA Example</title>
</head>
<body>
  <nav>
    <a href="/app1">App 1</a>
    <a href="/app2">App 2</a>
  </nav>

  <script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/single-spa.min.js"></script>
  <script src="./single-spa-config.js"></script>
</body>
</html>

2.3 优点和缺点

single-spa 的优点是:

  • 简单易用: 只需要几行代码就可以将多个前端应用整合在一起。
  • 技术栈无关: 可以支持多种前端框架。
  • 灵活: 可以根据路由动态加载和卸载应用。

single-spa 的缺点是:

  • 需要手动管理应用的生命周期: 需要手动编写 bootstrapmountunmount 函数。
  • 共享状态困难: 微前端应用之间共享状态比较困难,需要借助外部的状态管理工具。
  • 性能问题: 如果微前端应用过多,可能会导致页面加载速度变慢。

三、 厚积薄发:Module Federation

Module Federation 是 Webpack 5 引入的一个新特性,它允许我们将一个 Webpack 构建的应用拆分成多个独立的模块,这些模块可以在运行时被其他应用加载和使用。

Module Federation 最大的特点是:代码共享。它可以让我们在不同的应用之间共享代码,而不需要将代码复制到每个应用中。这大大提高了代码的复用率,减少了代码冗余。

3.1 核心概念

  • Host (宿主): 消费远程模块的应用。
  • Remote (远程): 提供可被其他应用消费的模块的应用。
  • Shared Modules (共享模块): 在 Host 和 Remote 之间共享的模块,例如 React, ReactDOM 等。

3.2 代码示例

假设我们有两个应用:app1app2app1 将作为 Host,app2 将作为 Remote。

首先,我们需要配置 app2 (Remote) 的 webpack.config.js 文件:

const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');

module.exports = {
  mode: 'development',
  devtool: 'inline-source-map',
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    publicPath: 'http://localhost:3001/', // 确保publicPath正确
    filename: 'bundle.js',
  },
  devServer: {
    port: 3001,
    hot: true,
  },
  module: {
    rules: [
      {
        test: /.jsx?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env', '@babel/preset-react'],
          },
        },
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'app2', // 必须唯一
      filename: 'remoteEntry.js', // 暴露模块的入口文件
      exposes: {
        './Button': './src/Button', // 暴露的模块,Key 是模块名,Value 是模块路径
      },
      shared: {
        react: { singleton: true, requiredVersion: '^17.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^17.0.0' },
      },
    }),
  ],
};

在这个配置中,我们使用了 ModuleFederationPlugin 插件。name 字段指定了 Remote 应用的名称,filename 字段指定了暴露模块的入口文件,exposes 字段指定了要暴露的模块,shared 字段指定了要共享的模块。

接下来,我们需要创建 app1 (Host) 的 webpack.config.js 文件:

const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');

module.exports = {
  mode: 'development',
  devtool: 'inline-source-map',
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    publicPath: 'http://localhost:3000/', // 确保publicPath正确
    filename: 'bundle.js',
  },
  devServer: {
    port: 3000,
    hot: true,
  },
  module: {
    rules: [
      {
        test: /.jsx?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env', '@babel/preset-react'],
          },
        },
      },
    ],
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'app1', // 必须唯一
      remotes: {
        app2: 'app2@http://localhost:3001/remoteEntry.js', // 引用远程模块,Key 是模块名,Value 是远程模块的地址
      },
      shared: {
        react: { singleton: true, requiredVersion: '^17.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^17.0.0' },
      },
    }),
  ],
};

在这个配置中,我们同样使用了 ModuleFederationPlugin 插件。name 字段指定了 Host 应用的名称,remotes 字段指定了要引用的远程模块,shared 字段指定了要共享的模块。

最后,我们需要在 app1 中使用 app2 暴露的 Button 组件:

app1/src/index.js:

import React from 'react';
import ReactDOM from 'react-dom';
// 注意:这里使用动态import,必须这样做
import('app2/Button').then(({ default: Button }) => {
    const App = () => (
        <div>
            <h1>App 1</h1>
            <Button text="Click me from App 2!" />
        </div>
    );

    ReactDOM.render(<App />, document.getElementById('root'));
});

app2/src/Button.js:

import React from 'react';

const Button = ({ text }) => (
  <button style={{ backgroundColor: 'lightblue' }}>{text}</button>
);

export default Button;

启动两个应用,你就可以在 app1 中看到 app2 提供的 Button 组件了。

3.3 优点和缺点

Module Federation 的优点是:

  • 代码共享: 可以极大地提高代码的复用率,减少代码冗余。
  • 独立部署: 每个应用都可以独立部署,互不影响。
  • 增量升级: 可以逐步升级应用,而不需要一次性重写整个应用。
  • 性能优化: 可以通过按需加载远程模块来优化页面加载速度。

Module Federation 的缺点是:

  • 配置复杂: 需要配置 Webpack,学习成本较高。
  • 版本冲突: 需要小心处理共享模块的版本冲突问题。
  • 类型定义: 在 TypeScript 项目中,共享模块的类型定义可能会比较麻烦。

四、 总结:single-spa vs Module Federation

特性 single-spa Module Federation
核心思想 一个页面,多个框架 代码共享
技术栈无关 支持 支持 (需要Webpack支持)
代码共享 困难,需要借助外部状态管理工具 容易,可以直接共享代码
部署方式 独立部署 独立部署
学习成本
性能 可能会有性能问题,需要优化 可以通过按需加载优化性能
使用场景 适合遗留系统的微前端改造,或技术栈差异较大的团队 适合大型应用的微前端拆分,或需要高度代码复用的场景

总的来说,single-spa 更加灵活,易于上手,但代码共享能力较弱。Module Federation 代码共享能力强大,但配置复杂,学习成本较高。选择哪种方案,需要根据具体的项目需求和团队情况来决定。

五、 未来展望

微前端技术还在不断发展,未来可能会出现更多新的解决方案。例如,基于 Web Components 的微前端方案,基于 Serverless 的微前端方案等等。

无论技术如何发展,微前端的核心思想都不会改变:将大型应用拆分成小的、可独立维护的单元,从而提高开发效率、降低维护成本。

好了,今天的分享就到这里。希望大家能够对微前端有一个更清晰的认识。如果大家还有什么问题,可以在评论区留言,我会尽力解答。

感谢大家的收听!下次再见!

补充说明:

上面的代码示例只是为了演示 single-spaModule Federation 的基本用法,实际项目中还需要考虑更多因素,例如:

  • 路由管理: 如何在微前端应用之间进行路由跳转?
  • 状态管理: 如何在微前端应用之间共享状态?
  • 通信: 如何在微前端应用之间进行通信?
  • 构建和部署: 如何自动化构建和部署微前端应用?
  • 安全性: 如何保证微前端应用的安全性?

这些问题都需要根据具体的项目需求来选择合适的解决方案。希望大家在实践中不断探索,不断学习,共同推动微前端技术的发展。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注