Go 组合层在混合微前端架构中集成 Dart、Lit 与 LlamaIndex 的实践


我们团队的技术文档现状是一团乱麻:一部分在 Confluence,一部分是散落在各个 Git 仓库里的 Markdown 文件,还有一部分藏在 OpenAPI 规范里。开发人员为了解一个完整的业务流程,往往需要切换三到四个平台。搭建一个统一的内部知识门户迫在眉睫,但新的问题随之而来:团队的前端技术栈并未统一。一部分工程师擅长使用 Dart 和 Flutter 构建复杂的交互式应用,而另一部分则更青睐基于 Web Components 的轻量级方案,如 Lit,用于构建内容展示型页面。

强制统一技术栈会扼杀生产力,而让两个团队各自为战又会产生两个独立的、体验割裂的系统。微前端是显而易见的解决方案,但市面上多数方案要么过于复杂,要么强依赖 JavaScript 生态(如 Webpack Module Federation),这对 Dart 团队并不友好。

初步的构想是回归简单:采用一个基于 Go 的后端服务,它不仅仅是 API 提供者,更是一个“服务器端组合层 (Server-Side Composition Layer)”。它的核心职责有两个:

  1. 在生产环境中,作为静态文件服务器,根据 URL 路径分发不同微前端应用的编译产物。
  2. 在开发环境中,充当一个智能反向代理,将请求无缝转发到各个微前端的本地开发服务器,从而解决跨域问题,并提供统一的入口。
  3. 承载核心业务逻辑,比如,集成 LlamaIndex 提供基于私有知识库的 RAG (检索增强生成) 问答能力。

这种架构的优势在于,它将前端的复杂性后移,前端应用可以保持技术独立性,只需关心自身的构建和部署,而不需要感知其他微前端的存在。

技术选型决策

  • 后端组合层 - Go (Gin 框架): 选择 Go 是因为它编译后是单一二进制文件,部署极其简单,资源占用小,且其网络性能和并发模型非常适合处理反向代理和 API 网关这类 I/O 密集型任务。Gin 框架则提供了足够简洁且高性能的路由和中间件能力。
  • 文档浏览微前端 - Lit & Shadcn UI 原则: 对于文档展示这类内容密集、交互相对简单的场景,Lit 基于标准 Web Components,轻量、无框架心智负担,生成的组件可以轻松嵌入任何页面。我们不直接使用 Shadcn UI 的 React/Svelte 版本,而是借鉴其设计哲学:不提供封装好的组件库,而是提供可直接复制、粘贴和修改的基础样式与结构代码 (基于 Tailwind CSS),这给予了我们极大的灵活性。
  • 智能问答微前端 - Dart (Flutter Web): 对于需要复杂状态管理、精细交互逻辑的 RAG 问答界面,Flutter Web 是一个理想选择。Dart 的强类型系统和 Flutter 声明式的 UI 框架能很好地驾驭复杂性。
  • AI 核心 - LlamaIndex (Go Port): 为了保持技术栈的统一性,我们选择使用 llama-index-go 这个 Go 语言移植版本。尽管它可能在功能上略滞后于 Python 主版本,但在一个 Go 项目中直接调用,无需维护跨语言的 RPC 或 Python 运行时环境,这对于简化运维和部署是巨大的胜利。在真实项目中,这种工程上的考量往往比追求最新的算法特性更重要。

步骤化实现:从项目结构到生产部署

1. 单体仓库 (Monorepo) 结构

为了便于管理,我们将所有代码放在一个 monorepo 中,结构如下:

/knowledge-portal
├── backend-go/                # Go 服务端组合层与 API
│   ├── main.go
│   ├── go.mod
│   ├── go.sum
│   ├── internal/
│   │   ├── api/               # LlamaIndex API 处理器
│   │   ├── config/            # 配置加载
│   │   ├── middleware/        # Gin 中间件
│   │   └── proxy/             # 反向代理逻辑
│   └── documents/             # 私有知识库源文件 (Markdown)
├── mfe-docs-lit/              # Lit 微前端 (文档浏览器)
│   ├── src/
│   ├── package.json
│   └── vite.config.ts
├── mfe-copilot-dart/          # Dart/Flutter 微前端 (智能问答)
│   ├── lib/
│   ├── pubspec.yaml
│   └── web/
└── Dockerfile                 # 生产环境 Dockerfile

2. Go 服务端组合层的实现

这是整个架构的核心。我们需要处理三种类型的请求:/api/*/copilot/* 和所有其他路径 (默认为文档微前端)。

backend-go/main.go

package main

import (
	"log"
	"net/http"
	"os"
	"path/filepath"
	"strings"

	"github.com/gin-gonic/gin"
	"knowledge-portal/internal/api"
	"knowledge-portal/internal/config"
	"knowledge-portal/internal/proxy"
)

func main() {
	// 加载配置
	cfg, err := config.Load()
	if err != nil {
		log.Fatalf("FATAL: Failed to load configuration: %v", err)
	}

	// 初始化 LlamaIndex 引擎
	queryEngine, err := api.SetupLlamaIndexEngine(cfg.DocumentsPath, cfg.EmbeddingModel, cfg.LlmUrl)
	if err != nil {
		log.Fatalf("FATAL: Failed to setup LlamaIndex engine: %v", err)
	}
	apiHandler := api.NewHandler(queryEngine)

	router := gin.Default()

	// API 路由组
	apiGroup := router.Group("/api")
	{
		apiGroup.POST("/query", apiHandler.HandleQuery)
	}

	// 微前端路由处理
	if cfg.Env == "production" {
		// 生产环境:提供静态文件
		setupProductionRoutes(router, cfg.Static.DocsPath, cfg.Static.CopilotPath)
	} else {
		// 开发环境:反向代理
		setupDevelopmentProxy(router, cfg.DevServer.DocsURL, cfg.DevServer.CopilotURL)
	}

	log.Printf("INFO: Starting server on %s in %s mode", cfg.Server.ListenAddr, cfg.Env)
	if err := router.Run(cfg.Server.ListenAddr); err != nil {
		log.Fatalf("FATAL: Failed to start server: %v", err)
	}
}

// setupProductionRoutes 配置生产环境的静态文件服务
func setupProductionRoutes(r *gin.Engine, docsPath, copilotPath string) {
	// Copilot 微前端
	copilotFS := http.Dir(copilotPath)
	r.StaticFS("/copilot", copilotFS)
	r.NoRoute(func(c *gin.Context) {
		if strings.HasPrefix(c.Request.URL.Path, "/copilot/") {
			c.File(filepath.Join(copilotPath, "index.html"))
			return
		}
		// 默认服务于 Docs 微前端
		// 这里的逻辑确保了 docs 应用可以处理 /docs/some-page 这样的深度链接
		if !filepath.HasPrefix(c.Request.URL.Path, "/assets/") {
			c.File(filepath.Join(docsPath, "index.html"))
		} else {
			http.FileServer(http.Dir(docsPath)).ServeHTTP(c.Writer, c.Request)
		}
	})
}


// setupDevelopmentProxy 配置开发环境的反向代理
func setupDevelopmentProxy(r *gin.Engine, docsURL, copilotURL string) {
	docsProxy, err := proxy.NewReverseProxy("docs-mfe", docsURL)
	if err != nil {
		log.Fatalf("FATAL: Failed to create docs reverse proxy: %v", err)
	}

	copilotProxy, err := proxy.NewReverseProxy("copilot-mfe", copilotURL)
	if err != nil {
		log.Fatalf("FATAL: Failed to create copilot reverse proxy: %v", err)
	}

	r.NoRoute(func(c *gin.Context) {
		path := c.Request.URL.Path
		if strings.HasPrefix(path, "/copilot") || strings.HasPrefix(path, "/main.dart.js") {
			// Flutter Web Dev Server 的特殊路径需要代理
			log.Printf("DEBUG: Proxying to copilot: %s", path)
			copilotProxy.ServeHTTP(c.Writer, c.Request)
		} else {
			// 其他所有请求都代理到 Docs MFE (Vite Dev Server)
			log.Printf("DEBUG: Proxying to docs: %s", path)
			docsProxy.ServeHTTP(c.Writer, c.Request)
		}
	})
}

反向代理的实现 backend-go/internal/proxy/proxy.go

package proxy

import (
	"log"
	"net/http"
	"net/http/httputil"
	"net/url"
)

// NewReverseProxy 创建一个新的反向代理处理器
func NewReverseProxy(name, targetURL string) (*httputil.ReverseProxy, error) {
	target, err := url.Parse(targetURL)
	if err != nil {
		return nil, err
	}

	proxy := httputil.NewSingleHostReverseProxy(target)

	// 自定义 Director 来重写请求头,这是生产级代理的关键
	originalDirector := proxy.Director
	proxy.Director = func(req *http.Request) {
		originalDirector(req)
		req.Host = target.Host // 关键:确保 Host header 正确,避免某些 dev server 的 vhost 问题
		req.Header.Set("X-Forwarded-Host", req.Header.Get("Host"))
		req.Header.Set("X-Real-IP", req.RemoteAddr)
		log.Printf("INFO: [%s] Forwarding request to %s", name, req.URL.String())
	}

	// 自定义错误处理器
	proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
		log.Printf("ERROR: [%s] Proxy error: %v", name, err)
		rw.WriteHeader(http.StatusBadGateway)
		_, _ = rw.Write([]byte("Proxy error"))
	}

	return proxy, nil
}

这里的日志和自定义 Director 是关键。在真实项目中,你不能简单地使用 NewSingleHostReverseProxy,必须处理好 Host header 和其他元信息,否则很多前端开发服务器会拒绝请求。

3. LlamaIndex API 的实现

backend-go/internal/api/query_handler.go

package api

import (
	"context"
	"log"
	"net/http"
	
	"github.com/gin-gonic/gin"
	"github.com/tmc/langchaingo/llms/ollama"
	"github.com/tmc/llama-index-go/core/base"
	"github.com/tmc/llama-index-go/core/engine"
	"github.com/tmc/llama-index-go/core/settings"
	"github.com/tmc/llama-index-go/readers"
	"github.com/tmc/llama-index-go/storage/index"
)

type Handler struct {
	queryEngine base.QueryEngine
}

func NewHandler(engine base.QueryEngine) *Handler {
	return &Handler{queryEngine: engine}
}

// SetupLlamaIndexEngine 初始化 LlamaIndex 引擎,这是个耗时操作,应在服务启动时完成
func SetupLlamaIndexEngine(docsPath, embedModel, llmUrl string) (base.QueryEngine, error) {
	log.Println("INFO: Setting up LlamaIndex engine...")
	
	// 配置 LLM 和 Embedding 模型 (这里使用 Ollama)
	llm, err := ollama.New(ollama.WithModel("llama3"), ollama.WithServerURL(llmUrl))
	if err != nil {
		return nil, err
	}
	settings.LLM = llm
	settings.EmbedModel = ollama.NewEmbedding(ollama.WithModel(embedModel), ollama.WithServerURL(llmUrl))
	settings.ChunkSize = 512

	// 加载文档
	docs, err := readers.NewSimpleDirectoryReader(docsPath).Load(context.Background())
	if err != nil {
		return nil, err
	}
	log.Printf("INFO: Loaded %d documents from %s", len(docs), docsPath)
	
	// 创建并持久化索引(在真实应用中,应检查索引是否存在,避免每次都重建)
	idx, err := index.NewVectorStoreIndex(context.Background(), docs)
	if err != nil {
		return nil, err
	}

	// 创建查询引擎
	queryEngine, err := engine.NewQueryEngine(idx)
	if err != nil {
		return nil, err
	}

	log.Println("INFO: LlamaIndex engine setup complete.")
	return queryEngine, nil
}

type QueryRequest struct {
	Query string `json:"query" binding:"required"`
}

// HandleQuery 处理来自前端的查询请求
func (h *Handler) HandleQuery(c *gin.Context) {
	var req QueryRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body: " + err.Error()})
		return
	}

	log.Printf("INFO: Received query: %s", req.Query)

	// 使用 LlamaIndex 执行查询
	// 在生产环境中,这里的 context 应该带有超时控制
	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
	defer cancel()

	res, err := h.queryEngine.Query(ctx, req.Query)
	if err != nil {
		log.Printf("ERROR: Query engine failed: %v", err)
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process query"})
		return
	}

	c.JSON(http.StatusOK, gin.H{"response": res.GetResponse()})
}

这个实现展示了如何在服务启动时预加载模型和索引,这是一个常见的性能优化。API 本身处理了 JSON 绑定和基本的错误响应。

4. Lit 文档浏览器微前端

我们使用 Vite 来构建 Lit 应用。
mfe-docs-lit/src/doc-browser.ts

import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { marked } from 'marked'; // Markdown to HTML converter

interface Document {
  id: string;
  title: string;
  content: string; // Base64 encoded markdown
}

@customElement('doc-browser')
export class DocBrowser extends LitElement {
  static styles = css`
    // 基于 Shadcn UI 原则的样式,使用 Tailwind CSS 的 @apply 指令
    // vite.config.ts 和 postcss.config.js 需要配置 Tailwind
    :host {
      display: grid;
      grid-template-columns: 280px 1fr;
      height: 100vh;
      background-color: var(--background);
      color: var(--foreground);
    }
    .sidebar {
      padding: 1rem;
      border-right: 1px solid var(--border);
      overflow-y: auto;
    }
    .sidebar ul {
      list-style: none;
      padding: 0;
    }
    .sidebar li {
      padding: 0.5rem 1rem;
      cursor: pointer;
      border-radius: 0.375rem;
    }
    .sidebar li:hover {
      background-color: var(--accent);
    }
    .sidebar li.active {
      background-color: var(--primary);
      color: var(--primary-foreground);
    }
    .content {
      padding: 2rem;
      overflow-y: auto;
    }
    /* ... 更多 prose 样式 for markdown content */
  `;

  @state()
  private _documents: Document[] = [];

  @state()
  private _activeDocId: string | null = null;
  
  @state()
  private _activeDocContent: string = '<p>Select a document to view.</p>';

  async connectedCallback() {
    super.connectedCallback();
    // 单元测试思路:mock fetch API,提供固定的 document 列表,
    // 然后断言 _documents 状态和渲染出的 li 元素数量是否正确。
    try {
      // 在真实项目中,这里应该从 /api/docs 获取文档列表
      // 为简化示例,我们使用 mock 数据
      this._documents = [
        { id: 'go-proxy', title: 'Go Proxy Implementation', content: btoa('# Go Proxy\n\nThis is the core of our architecture...') },
        { id: 'llama-setup', title: 'LlamaIndex Setup', content: btoa('## LlamaIndex\n\nConfiguration is key...') },
      ];
      if (this._documents.length > 0) {
        this._selectDocument(this._documents[0]);
      }
    } catch (error) {
      console.error('Failed to fetch documents:', error);
      this._activeDocContent = '<p>Error loading documents.</p>';
    }
  }

  private _selectDocument(doc: Document) {
    this._activeDocId = doc.id;
    // 解码并渲染 Markdown
    const markdownContent = atob(doc.content);
    this._activeDocContent = marked.parse(markdownContent) as string;
  }
  
  // 从文档页跳转到 Copilot 并传递上下文
  private _askCopilot() {
    if (!this._activeDocId) return;
    // 这里的坑在于,直接修改 window.location.href 会导致页面重载。
    // 在一个更复杂的 SPA/MFE 框架中,会使用 history API。
    // 在我们的服务器组合架构中,直接导航是可行的,因为服务器会路由到正确的 MFE。
    const currentTopic = this._documents.find(d => d.id === this._activeDocId)?.title;
    window.location.href = `/copilot?topic=${encodeURIComponent(currentTopic || '')}`;
  }

  render() {
    return html`
      <div class="sidebar">
        <h2>Documents</h2>
        <ul>
          ${this._documents.map(doc => html`
            <li 
              class=${this._activeDocId === doc.id ? 'active' : ''}
              @click=${() => this._selectDocument(doc)}
            >
              ${doc.title}
            </li>
          `)}
        </ul>
        <button @click=${() => window.location.href = '/copilot'}>Go to Copilot</button>
      </div>
      <div class="content">
        <button @click=${this._askCopilot}>Ask about this document</button>
        <article class="prose">
          ${unsafeHTML(this._activeDocContent)}
        </article>
      </div>
    `;
  }
}

5. Dart/Flutter 智能问答微前端

mfe-copilot-dart/lib/main.dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:html' as html; // 用于获取 URL 参数

void main() {
  runApp(const CopilotApp());
}

class CopilotApp extends StatelessWidget {
  const CopilotApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Knowledge Copilot',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        brightness: Brightness.dark,
      ),
      home: const CopilotHomePage(),
    );
  }
}

class CopilotHomePage extends StatefulWidget {
  const CopilotHomePage({super.key});

  
  State<CopilotHomePage> createState() => _CopilotHomePageState();
}

class _CopilotHomePageState extends State<CopilotHomePage> {
  final TextEditingController _controller = TextEditingController();
  String _response = '';
  bool _isLoading = false;

  
  void initState() {
    super.initState();
    // 检查 URL 中是否有 topic 参数,并预填充输入框
    final uri = Uri.tryParse(html.window.location.href);
    if (uri != null && uri.queryParameters.containsKey('topic')) {
      final topic = uri.queryParameters['topic'];
      _controller.text = 'Tell me more about "$topic".';
    }
  }

  Future<void> _sendQuery() async {
    if (_controller.text.isEmpty) return;

    setState(() {
      _isLoading = true;
      _response = '';
    });

    try {
      // Flutter Web 调用相对路径的 API 会自动请求到同源的 Go 服务器
      final response = await http.post(
        Uri.parse('/api/query'),
        headers: {'Content-Type': 'application/json'},
        body: json.encode({'query': _controller.text}),
      );

      if (response.statusCode == 200) {
        final data = json.decode(response.body);
        setState(() {
          _response = data['response'] ?? 'No response found.';
        });
      } else {
        // 健壮的错误处理:解析服务器返回的错误信息
        final errorData = json.decode(response.body);
        setState(() {
          _response = 'Error: ${response.statusCode}\n${errorData['error']}';
        });
      }
    } catch (e) {
      // 网络或其他异常
      setState(() {
        _response = 'Failed to send query: $e';
      });
      // 在生产级应用中,这里应该有日志上报
      debugPrint('Query failed: $e');
    } finally {
      setState(() {
        _isLoading = false;
      });
    }
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Knowledge Copilot'),
        leading: IconButton(
          icon: Icon(Icons.description),
          onPressed: () {
            // 返回文档微前端
            html.window.location.href = '/docs';
          },
        ),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            TextField(
              controller: _controller,
              decoration: InputDecoration(
                labelText: 'Ask anything about our knowledge base...',
                border: const OutlineInputBorder(),
                suffixIcon: IconButton(
                  icon: const Icon(Icons.send),
                  onPressed: _isLoading ? null : _sendQuery,
                ),
              ),
              onSubmitted: (_) => _sendQuery(),
            ),
            const SizedBox(height: 20),
            Expanded(
              child: Container(
                width: double.infinity,
                padding: const EdgeInsets.all(16.0),
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.grey[700]!),
                  borderRadius: BorderRadius.circular(8.0),
                ),
                child: _isLoading
                    ? const Center(child: CircularProgressIndicator())
                    : SingleChildScrollView(
                        child: SelectableText(_response),
                      ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

6. 生产环境构建与部署

最后,Dockerfile 将所有部分打包成一个独立的镜像。

Dockerfile

# --- Build Stage 1: Lit MFE ---
FROM node:20-alpine AS lit-builder
WORKDIR /app
COPY mfe-docs-lit/package*.json ./
RUN npm install
COPY mfe-docs-lit/ .
RUN npm run build

# --- Build Stage 2: Dart MFE ---
FROM dart:stable AS dart-builder
WORKDIR /app
COPY mfe-copilot-dart/ .
RUN dart pub get
RUN dart compile js -o build/web/main.dart.js lib/main.dart -O4

# --- Final Stage: Go Application ---
FROM golang:1.21-alpine AS final
WORKDIR /app

# 复制 Go 模块文件并下载依赖
COPY backend-go/go.mod backend-go/go.sum ./
RUN go mod download

# 复制 Go 源代码
COPY backend-go/ .

# 复制微前端的构建产物
COPY --from=lit-builder /app/dist /app/static/docs
COPY --from=dart-builder /app/build/web /app/static/copilot
# 复制知识库文档
COPY backend-go/documents ./documents

# 构建 Go 应用
# 使用 ldflags 可以在编译时注入版本信息等
# CGO_ENABLED=0 确保静态链接,生成更可移植的二进制文件
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .

# 使用一个轻量的基础镜像
FROM alpine:latest
WORKDIR /root/
COPY --from=final /app/main .
COPY --from=final /app/static ./static
COPY --from=final /app/documents ./documents

# 暴露端口
EXPOSE 8080

# 启动命令,通过环境变量来控制模式
# 这里的 config.yaml 需要通过 k8s configmap 或 docker volume 挂载
CMD ["./main"]

架构的可视化流程

sequenceDiagram
    participant User
    participant Browser
    participant GoServer as Go Composition Layer
    participant LitDevServer as Lit Dev Server (Vite)
    participant DartDevServer as Dart Dev Server (webdev)
    participant LlamaIndexEngine as LlamaIndex Engine (in Go)

    User->>Browser: Enters http://localhost:8080/docs/some-page
    Browser->>GoServer: GET /docs/some-page
    
    alt Development Mode
        GoServer->>LitDevServer: Proxy GET /docs/some-page
        LitDevServer-->>GoServer: Returns index.html + JS
        GoServer-->>Browser: Responds with MFE content
    else Production Mode
        GoServer-->>Browser: Serves static/docs/index.html
    end

    User->>Browser: Clicks "Ask Copilot" button
    Browser->>GoServer: GET /copilot?topic=...
    
    alt Development Mode
        GoServer->>DartDevServer: Proxy GET /copilot?topic=...
        DartDevServer-->>GoServer: Returns Flutter Web App
        GoServer-->>Browser: Responds with MFE content
    else Production Mode
        GoServer-->>Browser: Serves static/copilot/index.html
    end
    
    User->>Browser: Types query and clicks Send
    Browser->>GoServer: POST /api/query (JSON payload)
    GoServer->>LlamaIndexEngine: Calls queryEngine.Query()
    LlamaIndexEngine-->>GoServer: Returns response object
    GoServer-->>Browser: Responds with JSON
    Browser->>User: Renders the response

局限性与未来迭代路径

当前这个方案虽然简单有效,但并非没有缺点。一个最主要的问题是微前端之间的通信。我们使用了 URL 查询参数 (?topic=...) 这种最原始的方式传递上下文,这在简单场景下可行,但对于复杂的状态共享则无能为力。如果需要实现购物车、用户登录状态等跨 MFE 的共享状态,当前架构会变得非常笨拙。

其次,全局样式和资源共享也是一个挑战。每个 MFE 都是独立构建的,可能会导致通用 CSS、字体或图标被重复打包和加载。

未来的优化路径可以探索:

  1. 客户端通信总线: 在 Go 服务器注入的 index.html 模板中,加入一个轻量级的全局事件总线 (Event Bus) 的实现。各个 MFE 可以通过 window.dispatchEventwindow.addEventListener 来收发自定义事件,实现解耦的通信。
  2. Web Components 作为共享契约: 将通用的 UI 元素(如按钮、输入框)打包成独立的 Web Components 库,由两个 MFE 共同依赖。这能确保视觉上的一致性,并减少代码重复。
  3. 服务端的边缘状态管理: 对于用户会话这类关键状态,可以在 Go 层通过 Redis 或其他缓存进行管理,并通过一个专用的 /api/session 接口暴露给所有 MFE。这样可以避免在客户端同步状态的复杂性。
  4. 探索 Module Federation 的替代方案: 对于纯粹由 Dart 和 JS/TS 构成的微前端系统,可以研究 import maps 和 es-module-shims,探索在运行时动态加载 ES 模块的可能性,这比 Webpack 的 Module Federation 更接近 Web 标准。

  目录