在 Azure 平台上构建基于 Vite 的异构微前端架构:整合 Solid.js、Lit 与 Dart-WASM 的技术决策


我们面临的技术问题是明确的:一个庞大的、运行多年的金融分析平台需要进行现代化改造。该平台由多个业务团队维护,前端技术栈陈旧,单体式部署导致任何微小的改动都需要完整的回归测试和发布流程,效率极低。业务需求是,新架构必须支持不同技术栈的团队独立开发、测试和部署,同时,核心的风险计算模块对性能有极致要求,现有的JavaScript实现已达瓶颈。

传统的单一框架重构方案被迅速否决。强制统一技术栈会带来巨大的迁移成本和团队阻力。因此,微前端架构成为必然选择。然而,常见的基于Webpack Module Federation的方案虽然成熟,但其与Webpack深度绑定,配置复杂,且在构建性能上无法满足我们对极致开发者体验的追求。我们需要一个更轻量、更现代、基于浏览器原生能力(如ES Modules)的方案。

架构选型:为何选择ESM原生联邦而非Module Federation

我们的目标是构建一个基于Vite的微前端生态。Vite的核心优势在于其开发服务器利用了浏览器的原生ESM支持,实现了闪电般的冷启动和模块热更新。将这种理念延伸到微前端架构,意味着我们的微应用(Micro-Frontends, MFE)本身也应该被构建为标准的ESM模块。

方案A:Webpack Module Federation

  • 优点:
    • 生态成熟,有大量实践案例。
    • 功能强大,能够共享依赖、运行时注入模块。
  • 缺点:
    • 与Webpack生态强绑定,无法享受Vite带来的开发体验优势。
    • 运行时代码相对复杂,理解成本高。
    • 配置繁琐,尤其在处理CSS隔离和复杂依赖共享时。

方案B:基于原生ESM + Import Maps的动态加载

  • 优点:
    • 技术栈无关,任何能输出ESM的工具(如Vite, Rollup)都能集成。
    • 架构简单,主应用(Shell)仅需负责动态加载微应用的ESM入口文件。
    • 性能优异,依赖浏览器原生能力,没有额外的运行时开销。
    • 与Vite的开发哲学完美契合。
  • 缺点:
    • 需要自行处理依赖共享问题(例如通过Import Maps或外部CDN)。
    • 通信机制、样式隔离等问题需要自行设计解决方案。

最终决策:

我们选择方案B。对于一个追求极致性能和开发效率的新项目而言,利用平台原生能力总是更优的选择。虽然需要自行解决一些工程化问题,但这给予了我们更高的灵活性和掌控力。在真实项目中,这种掌控力对于长期维护至关重要。

整体架构设计

我们的架构由一个主应用(Shell)和多个微应用(MFE)组成。主应用负责路由、用户认证、以及加载和卸载微应用。微应用则由不同团队独立开发和部署。

graph TD
    subgraph Azure Static Web Apps
        A[主应用 Shell]
        B[路由与编排逻辑]
        C[全局通信总线 EventBus]
    end

    A -- 动态加载 --> D[MFE 1: Solid.js 应用]
    A -- 动态加载 --> E[MFE 2: Lit Web Component 应用]
    A -- 动态加载 --> F[MFE 3: Dart-WASM 高性能计算模块]

    subgraph MFE 1
        D
    end
    subgraph MFE 2
        E
    end
    subgraph MFE 3
        F
    end

    D -- 通信 --> C
    E -- 通信 --> C
    F -- 通信 --> C

    C -- 广播事件 --> D
    C -- 广播事件 --> E
    C -- 广播事件 --> F

    style A fill:#f9f,stroke:#333,stroke-width:2px
    style D fill:#bbf,stroke:#333,stroke-width:2px
    style E fill:#bfb,stroke:#333,stroke-width:2px
    style F fill:#fb9,stroke:#333,stroke-width:2px

主应用(Shell)实现

主应用本身极其轻量,不包含任何UI框架。其核心职责是解析URL,根据路由规则,动态地创建script标签来加载对应微应用的入口JS文件。

shell/main.js:

// A simple, framework-agnostic event bus
class EventBus extends EventTarget {
  emit(eventType, detail) {
    this.dispatchEvent(new CustomEvent(eventType, { detail }));
  }
}
window.globalEventBus = new EventBus();

// Application mount points
const apps = {
  '/profile': {
    name: 'mfe-solid-profile',
    // In production, this would point to the deployed MFE on Azure
    entry: 'http://localhost:5001/assets/index.js',
    loaded: false,
  },
  '/portfolio': {
    name: 'mfe-lit-portfolio',
    entry: 'http://localhost:5002/assets/index.js',
    loaded: false,
  },
  '/calculator': {
    name: 'mfe-dart-calculator',
    entry: 'http://localhost:5003/index.js', // WASM loader
    loaded: false,
  },
};

const appContainer = document.getElementById('app-container');

function loadApp(appConfig) {
  if (appConfig.loaded) {
    // If already loaded, just send an event to show it
    window.globalEventBus.emit(`app:show:${appConfig.name}`);
    return;
  }

  console.log(`Loading MFE: ${appConfig.name} from ${appConfig.entry}`);
  const script = document.createElement('script');
  script.type = 'module';
  script.src = appConfig.entry;
  script.onload = () => {
    console.log(`MFE loaded: ${appConfig.name}`);
    appConfig.loaded = true;
  };
  script.onerror = (err) => {
    console.error(`Failed to load MFE: ${appConfig.name}`, err);
    appContainer.innerHTML = `<h2>Error loading module. Please check the console.</h2>`;
  };
  document.head.appendChild(script);
}

function handleRouteChange() {
  const path = window.location.pathname;
  const activeApp = apps[path];

  // Hide all other apps
  Object.values(apps).forEach(app => {
    if (app.name !== activeApp?.name) {
      window.globalEventBus.emit(`app:hide:${app.name}`);
    }
  });

  if (activeApp) {
    loadApp(activeApp);
  } else {
    appContainer.innerHTML = '<h1>404 - Page Not Found</h1>';
  }
}

// Initial route handling
window.addEventListener('popstate', handleRouteChange);
document.addEventListener('DOMContentLoaded', () => {
  // Simple navigation handling
  document.querySelectorAll('nav a').forEach(link => {
    link.addEventListener('click', (e) => {
      e.preventDefault();
      history.pushState(null, '', e.target.href);
      handleRouteChange();
    });
  });
  handleRouteChange();
});

这里的核心是通过动态创建<script type="module">标签来加载微应用。加载完成后,微应用会自行监听事件总线上的事件,并将自己挂载到指定的DOM节点上。

微应用 1:基于 Solid.js 的用户配置模块

Solid.js因其极致的性能和类似React的开发体验而被一个团队选用。我们需要将其构建为一个独立的ESM模块。

mfe-solid-profile/vite.config.js:

import { defineConfig } from 'vite';
import solidPlugin from 'vite-plugin-solid';

export default defineConfig({
  plugins: [solidPlugin()],
  server: {
    port: 5001,
    // Required for MFE development to allow cross-origin requests
    cors: true,
  },
  build: {
    target: 'esnext',
    lib: {
      // Entry point for the library build
      entry: 'src/index.jsx',
      // The name of the global variable when exposed in UMD builds (not used here)
      name: 'MfeSolidProfile',
      // Output file name
      fileName: 'index',
      // We must output ES modules
      formats: ['es'],
    },
    // We don't want to bundle Solid.js into our MFE.
    // In a real project, it would be provided via an import map in the shell app.
    rollupOptions: {
      external: ['solid-js', 'solid-js/web'],
      output: {
        globals: {
          'solid-js': 'Solid',
          'solid-js/web': 'SolidWeb',
        },
      },
    },
  },
});

注意build.lib配置,这是Vite将应用构建为库(ESM)的关键。rollupOptions.external用于防止将Solid.js打包进去,假设它由主应用通过Import Map提供。

mfe-solid-profile/src/index.jsx:

import { render } from 'solid-js/web';
import { createSignal, onCleanup } from 'solid-js';

const MOUNT_POINT_ID = 'mfe-solid-profile-root';
const APP_NAME = 'mfe-solid-profile';

function ProfileApp() {
  const [user, setUser] = createSignal({ name: 'Alice', theme: 'dark' });
  const [message, setMessage] = createSignal('');

  // Listen to global events
  const handleGlobalMessage = (event) => {
    console.log(`[${APP_NAME}] Received global event:`, event.detail);
    setMessage(`Message from shell: ${JSON.stringify(event.detail)}`);
  };
  
  window.globalEventBus.addEventListener('message:broadcast', handleGlobalMessage);

  onCleanup(() => {
    window.globalEventBus.removeEventListener('message:broadcast', handleGlobalMessage);
  });

  function notifyShell() {
    const newTheme = user().theme === 'dark' ? 'light' : 'dark';
    setUser(u => ({ ...u, theme: newTheme }));

    console.log(`[${APP_NAME}] Emitting theme change event.`);
    // Emit a global event
    window.globalEventBus.emit('profile:themeChange', { theme: newTheme });
  }

  return (
    <div style={{ padding: '1rem', border: '2px solid #4d96ff', 'border-radius': '8px' }}>
      <h2>User Profile (Solid.js MFE)</h2>
      <p>Current User: {user().name}</p>
      <p>Theme: {user().theme}</p>
      <button onClick={notifyShell}>Toggle Theme & Notify Shell</button>
      {message() && <p style={{ color: 'green' }}>{message()}</p>}
    </div>
  );
}

// Mounting logic
function mount() {
  let mountPoint = document.getElementById(MOUNT_POINT_ID);
  if (!mountPoint) {
    mountPoint = document.createElement('div');
    mountPoint.id = MOUNT_POINT_ID;
    document.getElementById('app-container').appendChild(mountPoint);
  }
  mountPoint.style.display = 'block';
  render(() => <ProfileApp />, mountPoint);
}

function unmount() {
  const mountPoint = document.getElementById(MOUNT_POINT_ID);
  if (mountPoint) {
    // SolidJS render function returns a dispose method
    // In a real app, we'd manage this dispose call.
    // For simplicity, we just clear the innerHTML.
    mountPoint.innerHTML = '';
  }
}

function hide() {
    const mountPoint = document.getElementById(MOUNT_POINT_ID);
    if (mountPoint) mountPoint.style.display = 'none';
}

// MFE lifecycle hooks exposed to the shell via the event bus
window.globalEventBus.addEventListener(`app:show:${APP_NAME}`, mount);
window.globalEventBus.addEventListener(`app:hide:${APP_NAME}`, hide);

// Initial mount when the script is loaded
mount();

这个微应用在加载后,会立即执行mount()函数将自己渲染到主应用的容器中。它通过监听和触发window.globalEventBus上的自定义事件来与外部世界通信,实现了完全的解耦。

微应用 2:基于 Lit 的投资组合模块

另一个团队更熟悉Web Components标准,他们选择了Lit。Lit的优势在于它产出的是原生自定义元素,具有极佳的互操作性和封装性。

mfe-lit-portfolio/vite.config.js:

import { defineConfig } from 'vite';

export default defineConfig({
  server: {
    port: 5002,
    cors: true,
  },
  build: {
    target: 'esnext',
    lib: {
      entry: 'src/index.js',
      name: 'MfeLitPortfolio',
      fileName: 'index',
      formats: ['es'],
    },
    rollupOptions: {
      external: ['lit'],
    },
  },
});

配置与Solid.js的微应用类似,同样将lit外部化。

mfe-lit-portfolio/src/portfolio-component.js:

import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';

@customElement('portfolio-component')
export class PortfolioComponent extends LitElement {
  static styles = css`
    :host {
      display: block;
      padding: 1rem;
      border: 2px solid #34a853;
      border-radius: 8px;
    }
    button {
      background-color: #34a853;
      color: white;
      border: none;
      padding: 8px 12px;
      cursor: pointer;
    }
  `;

  @state()
  stocks = [
    { ticker: 'GOOG', shares: 100 },
    { ticker: 'AAPL', shares: 200 },
  ];

  @state()
  lastTheme = 'N/A';

  constructor() {
    super();
    // Subscribe to events from other MFEs
    window.globalEventBus.addEventListener('profile:themeChange', this.handleThemeChange);
  }

  disconnectedCallback() {
    super.disconnectedCallback();
    // Important for preventing memory leaks
    window.globalEventBus.removeEventListener('profile:themeChange', this.handleThemeChange);
  }

  handleThemeChange = (event) => {
    console.log('[mfe-lit-portfolio] Received theme change event:', event.detail);
    this.lastTheme = event.detail.theme;
  };

  addStock() {
    // In a real app, this would trigger an update that is saved somewhere
    const newTicker = `STOCK${Math.floor(Math.random() * 100)}`;
    this.stocks = [...this.stocks, { ticker: newTicker, shares: 50 }];
    
    // Announce the portfolio change to the world
    window.globalEventBus.emit('portfolio:updated', { count: this.stocks.length });
  }

  render() {
    return html`
      <h2>Portfolio (Lit MFE)</h2>
      <p>Last theme received from Profile MFE: <strong>${this.lastTheme}</strong></p>
      <ul>
        ${this.stocks.map(
          (stock) => html`<li>${stock.ticker}: ${stock.shares} shares</li>`
        )}
      </ul>
      <button @click=${this.addStock}>Add Random Stock & Notify</button>
    `;
  }
}

mfe-lit-portfolio/src/index.js:

// This file defines the custom element and handles mounting.
import './portfolio-component.js';

const MOUNT_POINT_ID = 'mfe-lit-portfolio-root';
const APP_NAME = 'mfe-lit-portfolio';

function mount() {
  let mountPoint = document.getElementById(MOUNT_POINT_ID);
  if (!mountPoint) {
    mountPoint = document.createElement('portfolio-component');
    mountPoint.id = MOUNT_POINT_ID;
    document.getElementById('app-container').appendChild(mountPoint);
  }
  mountPoint.style.display = 'block';
}

function hide() {
    const mountPoint = document.getElementById(MOUNT_POINT_ID);
    if (mountPoint) mountPoint.style.display = 'none';
}

window.globalEventBus.addEventListener(`app:show:${APP_NAME}`, mount);
window.globalEventBus.addEventListener(`app:hide:${APP_NAME}`, hide);

mount();

这里的关键是,Lit微应用将自身注册为一个自定义元素<portfolio-component>。它的挂载逻辑就是简单地将这个元素添加到DOM中。这展示了Web Components在微前端中的天然优势:强大的封装和互操作性。

微应用 3:Dart 到 WASM 的高性能计算模块

这是架构中最具挑战性的部分。我们需要将一个用Dart编写的复杂计算逻辑编译成WebAssembly,并在浏览器中调用它。这解决了JavaScript在CPU密集型任务上的性能短板。

mfe-dart-calculator/lib/calculator.dart:

import 'dart:js_interop';

// Annotate the function to export it to JavaScript.
()
double runMonteCarloSimulation(int simulations, double initialPrice, double drift, double volatility) {
  var random = JSNativeRandom();
  var sum = 0.0;

  for (var i = 0; i < simulations; i++) {
    var price = initialPrice;
    // Simplified Geometric Brownian Motion simulation
    for (var t = 0; t < 252; t++) { // 252 trading days in a year
      var randValue = random.next();
      // Box-Muller transform to get a standard normal random number
      var z = JSMath.sqrt(-2.0 * JSMath.log(randValue)) * JSMath.cos(2.0 * JSMath.pi * random.next());
      price *= JSMath.exp(drift - 0.5 * volatility * volatility + volatility * z);
    }
    sum += price;
  }

  return sum / simulations;
}

// We need to create JS-interop wrappers for browser APIs we want to use.
('Math.random')
external JSFunction _jsMathRandom;

('Math.sqrt')
external JSFunction _jsMathSqrt;

('Math.log')
external JSFunction _jsMathLog;

('Math.cos')
external JSFunction _jsMathCos;

('Math.PI')
external JSNumber _jsMathPI;

('Math.exp')
external JSFunction _jsMathExp;

// A small facade to make JS Math look more like Dart's.
class JSMath {
  static double sqrt(double val) => (_jsMathSqrt.callAsFunction(null, val.toJS) as JSNumber).toDartDouble;
  static double log(double val) => (_jsMathLog.callAsFunction(null, val.toJS) as JSNumber).toDartDouble;
  static double cos(double val) => (_jsMathCos.callAsFunction(null, val.toJS) as JSNumber).toDartDouble;
  static double exp(double val) => (_jsMathExp.callAsFunction(null, val.toJS) as JSNumber).toDartDouble;
  static double get pi => _jsMathPI.toDartDouble;
}

class JSNativeRandom {
  double next() => (_jsMathRandom.callAsFunction(null) as JSNumber).toDartDouble;
}

我们使用dart:js_interop来标注需要暴露给JavaScript的函数。一个常见的坑在于,在WASM环境中,不能直接调用dart:math中的Random,因为它依赖于Dart VM或操作系统层面的东西。这里的正确做法是通过JS-interop调用JavaScript的Math.random()

编译Dart到WASM需要使用dart compile wasm命令。

mfe-dart-calculator/build.sh:

#!/bin/bash
# Ensure you have the Dart SDK installed and configured.
dart pub get

# Compile the Dart code to WebAssembly
# The -O4 flag enables maximum optimization.
dart compile wasm -O4 lib/calculator.dart -o wasm_module/calculator.wasm

接下来,我们需要一个JavaScript加载器来实例化WASM模块,并将其功能封装成一个微应用。

mfe-dart-calculator/index.js:

const MOUNT_POINT_ID = 'mfe-dart-calculator-root';
const APP_NAME = 'mfe-dart-calculator';
let wasmExports = null;

async function initWasm() {
    if (wasmExports) return wasmExports;

    try {
        // In a real dev server setup, this URL would be correct.
        // For production on Azure, the path needs to be absolute.
        const response = await fetch('/wasm_module/calculator.wasm');
        const buffer = await response.arrayBuffer();
        const module = await WebAssembly.compile(buffer);

        // Dart's WASM output requires a specific import object structure.
        // We provide the JS functions that the Dart code expects.
        const imports = {
            // These are standard WASM imports Dart needs.
            dart_sdk: {
                // ... necessary SDK imports, often can be empty for simple cases
            },
            // Our custom imports for Math functions
            env: {
                "Math.random": Math.random,
                "Math.sqrt": Math.sqrt,
                "Math.log": Math.log,
                "Math.cos": Math.cos,
                "Math.PI": Math.PI,
                "Math.exp": Math.exp,
            },
        };

        const instance = await WebAssembly.instantiate(module, imports);
        wasmExports = instance.exports;
        console.log('[mfe-dart-calculator] WASM module initialized successfully.');
        return wasmExports;
    } catch (e) {
        console.error("Failed to initialize WASM module", e);
        throw e;
    }
}


function createUI() {
    const container = document.createElement('div');
    container.id = MOUNT_POINT_ID;
    container.style.border = '2px solid #fbbc05';
    container.style.padding = '1rem';
    container.style.borderRadius = '8px';

    container.innerHTML = `
        <h2>Risk Calculator (Dart-WASM MFE)</h2>
        <p>This component uses a WebAssembly module compiled from Dart for high-speed calculation.</p>
        <div>
            <label>Simulations: <input type="number" id="sim-count" value="1000000"></label>
            <button id="run-sim">Run Monte Carlo Simulation</button>
        </div>
        <div id="sim-result" style="margin-top: 1rem; font-weight: bold;"></div>
    `;

    container.querySelector('#run-sim').addEventListener('click', async () => {
        const resultDiv = container.querySelector('#sim-result');
        resultDiv.textContent = 'Calculating...';

        try {
            const wasmApi = await initWasm();
            const simulations = parseInt(container.querySelector('#sim-count').value, 10);
            
            // Performance measurement
            const startTime = performance.now();
            const result = wasmApi.runMonteCarloSimulation(simulations, 100.0, 0.0001, 0.01);
            const endTime = performance.now();

            resultDiv.textContent = `Calculated Average Price: ${result.toFixed(4)}. Took ${(endTime - startTime).toFixed(2)}ms.`;

            // Notify other parts of the application
            window.globalEventBus.emit('calculator:result', {
                result,
                duration: endTime - startTime,
            });

        } catch (e) {
            resultDiv.textContent = 'Error during calculation. See console.';
        }
    });

    return container;
}

function mount() {
    let mountPoint = document.getElementById(MOUNT_POINT_ID);
    if (!mountPoint) {
        const ui = createUI();
        document.getElementById('app-container').appendChild(ui);
    } else {
        mountPoint.style.display = 'block';
    }
}

function hide() {
    const mountPoint = document.getElementById(MOUNT_POINT_ID);
    if (mountPoint) mountPoint.style.display = 'none';
}

window.globalEventBus.addEventListener(`app:show:${APP_NAME}`, mount);
window.globalEventBus.addEventListener(`app:hide:${APP_NAME}`, hide);

mount();

这个JS加载器负责获取WASM文件、提供Dart代码所需的JavaScript函数作为imports,然后实例化模块。一旦WASM准备就绪,它就将导出的runMonteCarloSimulation函数连接到UI上。

部署到Azure Static Web Apps

Azure Static Web Apps是部署这种架构的理想选择。它原生支持托管静态文件,并可以通过staticwebapp.config.json文件配置路由。

staticwebapp.config.json:

{
  "navigationFallback": {
    "rewrite": "/index.html"
  },
  "routes": [
    {
      "route": "/profile",
      "rewrite": "/index.html"
    },
    {
      "route": "/portfolio",
      "rewrite": "/index.html"
    },
    {
      "route": "/calculator",
      "rewrite": "/index.html"
    }
  ],
  "globalHeaders": {
    "Access-Control-Allow-Origin": "*",
    "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; object-src 'none';"
  }
}

navigationFallback确保了所有未匹配到静态文件的路由都会返回index.html,让我们的客户端路由逻辑接管。一个关键点是globalHeaders,在开发阶段,我们可能需要允许跨域资源访问,但在生产环境中应配置更严格的CSP策略。

部署流程大致如下:

  1. 为每个微应用和主应用创建独立的CI/CD流水线(例如GitHub Actions)。
  2. 每个流水线在代码提交后,运行vite builddart compile,将产物(JS, CSS, WASM文件)上传到Azure Blob Storage的不同路径下。
  3. 主应用部署到Azure Static Web Apps。它会从Blob Storage中拉取各个微应用的最新版本。

架构的局限性与未来展望

此方案并非银弹。首先,跨技术栈共享状态依然是一个难题。我们的事件总线方案适用于简单的通知和命令,但对于复杂的共享状态(如用户信息),可能需要引入一个独立的、框架无关的状态管理库(如Redux或Zustand,并暴露其store实例)或者通过Service Worker进行状态同步。

其次,依赖管理需要严格规范。虽然我们将solid-jslit等核心库外部化,但这依赖于主应用通过Import Maps正确地提供了这些依赖。版本冲突可能会成为一个严重问题。在生产环境中,需要一个中心化的清单文件来管理所有微应用的版本及其共享依赖的版本,确保一致性。

最后,Dart-WASM的工具链虽然在不断成熟,但与成熟的JavaScript生态相比,调试和集成的复杂度更高。尤其是JS-interop部分的类型安全和性能开销,需要开发团队具备更深的底层知识。未来的方向可能是探索WASI(WebAssembly System Interface)以减少对JavaScript胶水代码的依赖,让WASM模块更加独立和可移植。


  目录