微前端實戰:大型前端應用的拆分與治理

远洋录發表於2024-12-10

"這個系統太龐大了,每次釋出都提心吊膽..." 上個月的技術評審會上,我們團隊正面臨一個棘手的問題。一個執行了兩年的企業級中後臺系統,程式碼量超過 30 萬行,構建時間長達 20 分鐘,任何小改動都可能引發意想不到的問題。作為技術負責人,我決定是時候引入微前端架構了。

經過一個月的改造,我們成功將這個龐然大物拆分成多個獨立應用,構建時間縮短到了 3 分鐘,各個團隊也能獨立開發部署了。今天就來分享這次微前端改造的實戰經驗。

為什麼選擇微前端?

說實話,剛開始團隊對微前端也有顧慮 - 會不會過度設計?效能會不會受影響?但當我們列出現有問題時,答案就很明顯了:

// 原有的單體應用結構
const LegacyApp = {
  modules: {
    crm: {
      size: '12MB JS + 2MB CSS',
      team: 'A團隊',
      updateFrequency: '每週2次'
    },
    erp: {
      size: '15MB JS + 3MB CSS',
      team: 'B團隊',
      updateFrequency: '每天1次'
    },
    dashboard: {
      size: '8MB JS + 1MB CSS',
      team: 'C團隊',
      updateFrequency: '每月2次'
    }
  },
  problems: {
    buildTime: '20min+',
    deployment: '全量釋出',
    teamCollaboration: '程式碼衝突頻繁',
    maintenance: '難以區域性更新'
  }
}

架構設計與實現

1. 基座應用

首先,我們需要一個輕量級的基座應用來管理子應用:

// 基座應用 - App Shell
import { registerApplication, start } from 'single-spa'

// 註冊子應用
const registerMicroApp = (name: string, entry: string) => {
  registerApplication({
    name,
    app: async () => {
      // 動態載入子應用
      const module = await System.import(entry)
      return module.default
    },
    activeWhen: location => {
      // 基於路由匹配啟用子應用
      return location.pathname.startsWith(`/${name}`)
    }
  })
}

// 配置子應用
const microApps = [
  {
    name: 'crm',
    entry: '//localhost:3001/main.js',
    container: '#crm-container'
  },
  {
    name: 'erp',
    entry: '//localhost:3002/main.js',
    container: '#erp-container'
  },
  {
    name: 'dashboard',
    entry: '//localhost:3003/main.js',
    container: '#dashboard-container'
  }
]

// 註冊所有子應用
microApps.forEach(app => registerMicroApp(app.name, app.entry))

// 啟動微前端框架
start()

2. 子應用改造

每個子應用需要暴露生命週期鉤子:

// 子應用入口
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
import { createStore } from './store'

// 匯出生命週期鉤子
export async function bootstrap() {
  console.log('CRM 應用啟動中...')
}

export async function mount(props) {
  const { container, globalStore } = props
  const store = createStore(globalStore)

  ReactDOM.render(
    <Provider store={store}>
      <App />
    </Provider>,
    container
  )
}

export async function unmount(props) {
  const { container } = props
  ReactDOM.unmountComponentAtNode(container)
}

3. 通訊機制

子應用間的通訊是個關鍵問題,我們實現了一個事件匯流排:

// utils/eventBus.ts
class EventBus {
  private events = new Map<string, Function[]>()

  // 訂閱事件
  on(event: string, callback: Function) {
    if (!this.events.has(event)) {
      this.events.set(event, [])
    }
    this.events.get(event)!.push(callback)

    // 返回取消訂閱函式
    return () => {
      const callbacks = this.events.get(event)!
      const index = callbacks.indexOf(callback)
      callbacks.splice(index, 1)
    }
  }

  // 釋出事件
  emit(event: string, data?: any) {
    if (!this.events.has(event)) return
    this.events.get(event)!.forEach(callback => {
      try {
        callback(data)
      } catch (error) {
        console.error(`Error in event ${event}:`, error)
      }
    })
  }
}

export const eventBus = new EventBus()

// 使用示例
// CRM 子應用
eventBus.emit('orderCreated', { orderId: '123' })

// ERP 子應用
eventBus.on('orderCreated', data => {
  updateInventory(data.orderId)
})

4. 樣式隔離

為了避免樣式衝突,我們採用了 CSS Modules 和動態 CSS 字首:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              modules: {
                localIdentName: '[name]__[local]___[hash:base64:5]'
              }
            }
          },
          {
            loader: 'postcss-loader',
            options: {
              plugins: [
                require('postcss-prefix-selector')({
                  prefix: '[data-app="crm"]'
                })
              ]
            }
          }
        ]
      }
    ]
  }
}

效能最佳化

微前端雖然解決了很多問題,但也帶來了新的挑戰,比如首屏載入效能。我們透過以下方式進行最佳化:

  1. 預載入策略:
// 基於路由預測使用者行為
const prefetchApps = async () => {
  const nextPossibleApps = predictNextApps()

  // 預載入可能用到的子應用
  nextPossibleApps.forEach(app => {
    const script = document.createElement('link')
    script.rel = 'prefetch'
    script.href = app.entry
    document.head.appendChild(script)
  })
}
  1. 共享依賴:
// webpack.config.js
module.exports = {
  externals: {
    react: 'React',
    'react-dom': 'ReactDOM',
    antd: 'antd'
  },
  // 使用 CDN 載入共享依賴
  scripts: ['https://unpkg.com/react@17/umd/react.production.min.js', 'https://unpkg.com/react-dom@17/umd/react-dom.production.min.js', 'https://unpkg.com/antd@4/dist/antd.min.js']
}

實踐心得

這次微前端改造讓我深刻體會到:

  1. 架構改造要循序漸進,先從邊界清晰的模組開始
  2. 子應用拆分要基於業務邊界,而不是技術邊界
  3. 通訊機制要簡單可靠,避免複雜的狀態同步
  4. 持續關注效能指標,及時發現和解決問題

最讓我欣慰的是,改造後團隊的開發效率明顯提升,釋出也更加靈活可控。正如那句話說的:"合久必分,分久必合。"在前端架構的演進中,找到當下最合適的平衡點才是關鍵。

寫在最後

微前端不是銀彈,它更像是一把雙刃劍 - 使用得當可以解決很多問題,但也可能引入新的複雜性。關鍵是要根據團隊和業務的實際情況,做出合適的選擇。

有什麼問題歡迎在評論區討論,我們一起探討微前端實踐的更多可能!

如果覺得有幫助,別忘了點贊關注,我會繼續分享更多架構實戰經驗~

相關文章