隨着前端應用日益複雜,微前端架構已成為解決單體前端應用痛點的有效方案。本文將深入探討如何在PHP全棧應用中實現微前端架構,包括模塊化設計、組件共享、狀態管理和部署策略。

微前端基礎架構

主應用集成

// src/Controller/MicroFrontendController.php
namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class MicroFrontendController
{
    private HttpClientInterface $httpClient;
    private string $assetManifestUrl;
    
    public function __construct(
        HttpClientInterface $httpClient,
        string $assetManifestUrl
    ) {
        $this->httpClient = $httpClient;
        $this->assetManifestUrl = $assetManifestUrl;
    }
    
    /**
     * @Route("/dashboard", name="micro_frontend_dashboard")
     */
    public function dashboard(): Response
    {
        // 1. 獲取微前端資產清單
        $manifest = $this->fetchAssetManifest();
        
        // 2. 渲染主模板並注入微前端配置
        return $this->render('micro_frontend/dashboard.html.twig', [
            'mfConfig' => [
                'dashboard' => [
                    'js' => $manifest['dashboard.js'],
                    'css' => $manifest['dashboard.css'],
                    'mountPoint' => '#dashboard-container'
                ]
            ]
        ]);
    }
    
    private function fetchAssetManifest(): array
    {
        try {
            $response = $this->httpClient->request(
                'GET',
                $this->assetManifestUrl
            );
            
            return $response->toArray();
        } catch (\Exception $e) {
            throw new \RuntimeException(
                'Failed to fetch micro frontend asset manifest',
                0,
                $e
            );
        }
    }
}

前端主框架實現

// assets/js/micro-frontend-loader.js
class MicroFrontendLoader {
  constructor(config) {
    this.config = config;
    this.loadedUris = new Set();
    this.mountedApps = new Map();
  }

  async loadApp(name) {
    const appConfig = this.config[name];
    
    if (!appConfig) {
      throw new Error(`Micro frontend config not found for ${name}`);
    }

    // 加載CSS
    if (appConfig.css && !this.loadedUris.has(appConfig.css)) {
      await this.loadCss(appConfig.css);
      this.loadedUris.add(appConfig.css);
    }

    // 加載JS
    if (appConfig.js && !this.loadedUris.has(appConfig.js)) {
      await this.loadScript(appConfig.js);
      this.loadedUris.add(appConfig.js);
    }

    return window[`mount${name.charAt(0).toUpperCase() + name.slice(1)}`];
  }

  async mountApp(name, props = {}) {
    if (this.mountedApps.has(name)) {
      return this.mountedApps.get(name);
    }

    const mountFn = await this.loadApp(name);
    const mountPoint = document.querySelector(this.config[name].mountPoint);

    if (!mountPoint) {
      throw new Error(`Mount point not found for ${name}`);
    }

    const unmountFn = mountFn(mountPoint, props);
    this.mountedApps.set(name, unmountFn);

    return unmountFn;
  }

  unmountApp(name) {
    if (this.mountedApps.has(name)) {
      this.mountedApps.get(name)();
      this.mountedApps.delete(name);
    }
  }

  loadScript(src) {
    return new Promise((resolve, reject) => {
      const script = document.createElement('script');
      script.src = src;
      script.onload = resolve;
      script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
      document.head.appendChild(script);
    });
  }

  loadCss(href) {
    return new Promise((resolve, reject) => {
      const link = document.createElement('link');
      link.rel = 'stylesheet';
      link.href = href;
      link.onload = resolve;
      link.onerror = () => reject(new Error(`Failed to load CSS: ${href}`));
      document.head.appendChild(link);
    });
  }
}

// 初始化加載器
const loader = new MicroFrontendLoader(window.mfConfig);

// 導出為全局對象
window.MicroFrontendLoader = loader;

微前端模塊開發

獨立構建的微前端

// dashboard-mf/src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import DashboardApp from './DashboardApp';

// 掛載函數
export function mountDashboard(container, props) {
  ReactDOM.render(
    <DashboardApp {...props} />,
    container
  );

  // 返回卸載函數
  return () => {
    ReactDOM.unmountComponentAtNode(container);
  };
}

// 開發環境獨立運行
if (process.env.NODE_ENV === 'development') {
  const devContainer = document.getElementById('root');
  if (devContainer) {
    mountDashboard(devContainer, {
      apiUrl: 'http://localhost:8000/api',
      user: { id: 1, name: 'Developer' }
    });
  }
}

Webpack配置示例

// dashboard-mf/webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  entry: './src/index.js',
  output: {
    publicPath: 'http://localhost:3001/',
    filename: '[name].[contenthash].js'
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          presets: ['@babel/preset-react']
        }
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      }
    ]
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'dashboard',
      filename: 'remoteEntry.js',
      exposes: {
        './DashboardApp': './src/DashboardApp'
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true },
        'react-router-dom': { singleton: true }
      }
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html'
    })
  ],
  devServer: {
    port: 3001,
    historyApiFallback: true,
    headers: {
      'Access-Control-Allow-Origin': '*'
    }
  }
};

PHP與微前端通信

// src/Service/MicroFrontendDataProvider.php
namespace App\Service;

use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Psr\Cache\CacheItemInterface;

class MicroFrontendDataProvider
{
    private HttpClientInterface $httpClient;
    private FilesystemAdapter $cache;
    private string $apiBaseUrl;
    
    public function __construct(
        HttpClientInterface $httpClient,
        string $apiBaseUrl
    ) {
        $this->httpClient = $httpClient;
        $this->cache = new FilesystemAdapter();
        $this->apiBaseUrl = rtrim($apiBaseUrl, '/');
    }
    
    public function getDashboardData(int $userId): array
    {
        $cacheKey = "dashboard_data_{$userId}";
        $cacheItem = $this->cache->getItem($cacheKey);
        
        if ($cacheItem->isHit()) {
            return $cacheItem->get();
        }
        
        $data = $this->fetchDashboardData($userId);
        $cacheItem->set($data)->expiresAfter(300); // 5分鐘緩存
        
        $this->cache->save($cacheItem);
        
        return $data;
    }
    
    private function fetchDashboardData(int $userId): array
    {
        try {
            $response = $this->httpClient->request(
                'GET',
                "{$this->apiBaseUrl}/dashboard/{$userId}"
            );
            
            return $response->toArray();
        } catch (\Exception $e) {
            throw new \RuntimeException(
                'Failed to fetch dashboard data',
                0,
                $e
            );
        }
    }
    
    public function invalidateCache(int $userId): void
    {
        $this->cache->deleteItem("dashboard_data_{$userId}");
    }
}

共享組件與狀態管理

跨微前端共享組件

// shared-components/src/Button.jsx
import React from 'react';
import PropTypes from 'prop-types';
import './Button.css';

export function Button({
  children,
  primary = false,
  size = 'medium',
  onClick,
  ...props
}) {
  const mode = primary ? 'btn-primary' : 'btn-secondary';
  
  return (
    <button
      type="button"
      className={`btn btn-${size} ${mode}`}
      onClick={onClick}
      {...props}
    >
      {children}
    </button>
  );
}

Button.propTypes = {
  children: PropTypes.node.isRequired,
  primary: PropTypes.bool,
  size: PropTypes.oneOf(['small', 'medium', 'large']),
  onClick: PropTypes.func
};

// shared-components/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  output: {
    publicPath: 'http://localhost:3003/'
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'sharedComponents',
      filename: 'remoteEntry.js',
      exposes: {
        './Button': './src/Button',
        './Card': './src/Card',
        './Modal': './src/Modal'
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true },
        'prop-types': { singleton: true }
      }
    })
  ]
};

全局狀態管理

// shared-state/src/store.js
import { createStore } from 'redux';

const initialState = {
  user: null,
  notifications: [],
  preferences: {
    theme: 'light',
    locale: 'en-US'
  }
};

function reducer(state = initialState, action) {
  switch (action.type) {
    case 'SET_USER':
      return { ...state, user: action.payload };
    case 'ADD_NOTIFICATION':
      return {
        ...state,
        notifications: [...state.notifications, action.payload]
      };
    case 'UPDATE_PREFERENCES':
      return {
        ...state,
        preferences: { ...state.preferences, ...action.payload }
      };
    default:
      return state;
  }
}

const store = createStore(reducer);

// 訂閲函數用於跨微前端同步狀態
export function subscribeToStore(callback) {
  let currentState = store.getState();
  
  const handleChange = () => {
    const nextState = store.getState();
    if (nextState !== currentState) {
      currentState = nextState;
      callback(currentState);
    }
  };
  
  const unsubscribe = store.subscribe(handleChange);
  return unsubscribe;
}

export function getState() {
  return store.getState();
}

export function dispatch(action) {
  store.dispatch(action);
}

// 在微前端中集成
export function connectToStore(Component) {
  return function WrappedComponent(props) {
    const [state, setState] = React.useState(getState());
    
    React.useEffect(() => {
      return subscribeToStore(setState);
    }, []);
    
    return <Component {...props} {...state} dispatch={dispatch} />;
  };
}

路由與導航

前端路由集成

// shell-app/src/App.jsx
import React from 'react';
import { BrowserRouter, Route, Switch, Link } from 'react-router-dom';
import { MicroFrontend } from './MicroFrontend';

const DashboardPage = ({ history }) => (
  <MicroFrontend
    host="http://localhost:3001"
    name="dashboard"
    history={history}
    props={{ apiUrl: '/api' }}
  />
);

const ProfilePage = ({ history }) => (
  <MicroFrontend
    host="http://localhost:3002"
    name="profile"
    history={history}
    props={{ apiUrl: '/api' }}
  />
);

export default function App() {
  return (
    <BrowserRouter>
      <div className="app-shell">
        <nav className="app-nav">
          <Link to="/">Dashboard</Link>
          <Link to="/profile">Profile</Link>
        </nav>
        
        <Switch>
          <Route exact path="/" component={DashboardPage} />
          <Route path="/profile" component={ProfilePage} />
        </Switch>
      </div>
    </BrowserRouter>
  );
}

// shell-app/src/MicroFrontend.jsx
import React, { useEffect, useRef } from 'react';

export function MicroFrontend({ host, name, history, props }) {
  const containerRef = useRef(null);
  
  useEffect(() => {
    const scriptId = `micro-frontend-script-${name}`;
    
    const renderMicroFrontend = () => {
      window[`mount${name}`](containerRef.current, {
        ...props,
        history
      });
    };
    
    if (document.getElementById(scriptId)) {
      renderMicroFrontend();
      return;
    }
    
    fetch(`${host}/asset-manifest.json`)
      .then(res => res.json())
      .then(manifest => {
        const script = document.createElement('script');
        script.id = scriptId;
        script.src = `${host}${manifest['main.js']}`;
        script.onload = renderMicroFrontend;
        document.head.appendChild(script);
      });
    
    return () => {
      window[`unmount${name}`] && window[`unmount${name}`]();
    };
  }, [host, name, props, history]);
  
  return <div ref={containerRef} />;
}

後端路由代理

// src/Controller/RouterController.php
namespace App\Controller;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class RouterController
{
    private HttpClientInterface $httpClient;
    private array $microFrontendConfig;
    
    public function __construct(
        HttpClientInterface $httpClient,
        array $microFrontendConfig
    ) {
        $this->httpClient = $httpClient;
        $this->microFrontendConfig = $microFrontendConfig;
    }
    
    /**
     * @Route("/{path}", name="micro_frontend_router", requirements={"path"="^(?!api|_profiler).+"})
     */
    public function route(Request $request, string $path = ''): Response
    {
        $route = $path ?: 'dashboard';
        
        if (!isset($this->microFrontendConfig[$route])) {
            throw $this->createNotFoundException(
                "Micro frontend route {$route} not found"
            );
        }
        
        $config = $this->microFrontendConfig[$route];
        
        try {
            $response = $this->httpClient->request(
                'GET',
                $config['server'] . '/' . ltrim($path, '/')
            );
            
            return new Response(
                $response->getContent(),
                $response->getStatusCode(),
                ['Content-Type' => 'text/html']
            );
        } catch (\Exception $e) {
            return new Response(
                $this->renderView('error/micro_frontend.html.twig', [
                    'route' => $route
                ]),
                Response::HTTP_BAD_GATEWAY
            );
        }
    }
    
    /**
     * @Route("/api/micro-frontends", name="micro_frontend_config")
     */
    public function getConfig(): JsonResponse
    {
        return $this->json(array_map(
            fn($config) => [
                'js' => $config['js'],
                'css' => $config['css']
            ],
            $this->microFrontendConfig
        ));
    }
}

部署策略與優化

靜態資源版本控制

// src/Service/AssetVersionStrategy.php
namespace App\Service;

use Symfony\Component\Asset\VersionStrategy\VersionStrategyInterface;

class AssetVersionStrategy implements VersionStrategyInterface
{
    private string $manifestPath;
    private ?array $manifest = null;
    private string $format;
    
    public function __construct(
        string $manifestPath,
        string $format = '%s?%s'
    ) {
        $this->manifestPath = $manifestPath;
        $this->format = $format;
    }
    
    public function getVersion(string $path): string
    {
        return $this->getManifestHash($path) ?: '';
    }
    
    public function applyVersion(string $path): string
    {
        $version = $this->getVersion($path);
        
        if ('' === $version) {
            return $path;
        }
        
        return sprintf($this->format, $path, $version);
    }
    
    private function getManifestHash(string $path): ?string
    {
        if (null === $this->manifest) {
            if (!file_exists($this->manifestPath)) {
                return null;
            }
            
            $this->manifest = json_decode(
                file_get_contents($this->manifestPath),
                true
            );
        }
        
        return $this->manifest[$path] ?? null;
    }
}

Nginx配置優化

# /etc/nginx/conf.d/micro-frontend.conf
server {
    listen 80;
    server_name app.example.com;
    
    root /var/www/html/public;
    
    location / {
        try_files $uri /index.php$is_args$args;
    }
    
    # 微前端靜態資源
    location ~ ^/assets/(dashboard|profile|settings)/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }
    
    # API路由
    location /api {
        try_files $uri /index.php$is_args$args;
    }
    
    location ~ ^/index\.php(/|$) {
        fastcgi_pass php-fpm:9000;
        fastcgi_split_path_info ^(.+\.php)(/.*)$;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
        internal;
    }
    
    location ~ \.php$ {
        return 404;
    }
    
    error_log /var/log/nginx/error.log;
    access_log /var/log/nginx/access.log;
}

CI/CD管道配置

# .github/workflows/micro-frontend.yml
name: Micro Frontend CI/CD

on:
  push:
    branches: [ main ]
    paths:
      - 'dashboard-mf/**'
      - 'profile-mf/**'
      - 'shared-components/**'

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        mf: ['dashboard-mf', 'profile-mf', 'shared-components']
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Use Node.js
      uses: actions/setup-node@v3
      with:
        node-version: 16
    
    - name: Install dependencies
      working-directory: ./${{ matrix.mf }}
      run: npm install
    
    - name: Build
      working-directory: ./${{ matrix.mf }}
      run: npm run build
    
    - name: Upload artifacts
      uses: actions/upload-artifact@v3
      with:
        name: ${{ matrix.mf }}-assets
        path: ${{ matrix.mf }}/dist/
        retention-days: 1

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    
    steps:
    - name: Download artifacts
      uses: actions/download-artifact@v3
      with:
        path: artifacts
    
    - name: Deploy to S3
      uses: jakejarvis/s3-sync-action@v0.5.1
      with:
        args: --acl public-read --delete
      env:
        AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
        AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
        AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        AWS_REGION: 'us-east-1'
        SOURCE_DIR: 'artifacts'
    
    - name: Update asset manifest
      run: |
        aws s3 cp s3://${{ secrets.AWS_S3_BUCKET }}/asset-manifest.json asset-manifest.json || echo '{}' > asset-manifest.json
        
        for mf in dashboard-mf profile-mf shared-components; do
          jq -s '.[0] * .[1]' asset-manifest.json artifacts/$mf/dist/asset-manifest.json > merged.json
          mv merged.json asset-manifest.json
        done
        
        aws s3 cp asset-manifest.json s3://${{ secrets.AWS_S3_BUCKET }}/asset-manifest.json --acl public-read

結語

本文深入探討了PHP全棧應用中的微前端架構實踐,包括:

  1. 微前端基礎架構:主應用集成與模塊加載機制
  2. 獨立模塊開發:組件封裝與獨立構建部署
  3. 共享資源管理:組件庫與狀態管理方案
  4. 路由導航方案:前後端路由協同
  5. 部署優化策略:靜態資源管理與CI/CD管道

這些技術實踐為PHP全棧開發帶來以下優勢:

  • 增量升級:逐步替換舊功能而不影響整體應用
  • 獨立部署:各功能模塊可獨立開發與發佈
  • 技術異構:不同模塊可採用不同前端技術棧
  • 團隊自治:各團隊可專注於特定業務領域

實施建議:

  1. 漸進式採用:從非核心功能開始引入微前端
  2. 統一規範:制定組件接口與通信標準
  3. 性能監控:關注資源加載與運行時性能
  4. 開發體驗:建立本地開發協作機制
  5. 測試策略:完善集成測試與契約測試

通過合理應用微前端架構,PHP全棧應用可以兼具單體應用的簡單性和分佈式系統的靈活性,為複雜業務場景提供可持續的解決方案。