single-spa微前端項(xiàng)目落地

前言

????由于公司當(dāng)前項(xiàng)目過于臃腫,打包速度越來越慢,同時(shí)在每次代碼合并時(shí),會(huì)出現(xiàn)非常多的沖突。因此,希望找到一種方式,來減小項(xiàng)目體積,又不影響現(xiàn)有代碼的方式。在尋找過程中,發(fā)現(xiàn)微前端是一種很不錯(cuò)的方式,技術(shù)無關(guān),同時(shí)可以分開部署,簡直完美。

????目前主流的微前端方式,主要有iframe,single-spa,qiankun,micro-app以及webpack5的module ferderation等。鑒于我們當(dāng)前項(xiàng)目是以webpack4為主,首先排除了module ferderation。micro-app是京東開源的微前端框架,基于shadowdom實(shí)現(xiàn),shadowdom容易出現(xiàn)一些問題,如iconfront顯示問題,因此跳過。qiankun是螞蟻集團(tuán)基于single-spa進(jìn)行的封裝,但基于更喜歡自己封裝,遂最終選擇了single-spa。

single-spa

single-spa實(shí)現(xiàn)原理:首先對(duì)微前端路由進(jìn)行注冊(cè),使用single-spa充當(dāng)微前端加載器,并做為項(xiàng)目單一入口來接受全部頁面URL的訪問,根據(jù)頁面URL與微前端的匹配關(guān)系,選擇加載對(duì)應(yīng)的微前端模塊,再由該微前端模塊進(jìn)行路由響應(yīng)URL,即微前端模塊中路由找到相應(yīng)的組件,渲染頁面內(nèi)容。

single-spa實(shí)現(xiàn)過程

  1. 基座項(xiàng)目

基于vue的基座項(xiàng)目,使用vue-cli創(chuàng)建基座項(xiàng)目

vue create micro-front-cli-root-config
  • 首先在dom創(chuàng)建節(jié)點(diǎn)掛載子項(xiàng)目,子項(xiàng)目注冊(cè)后即可掛載在基座項(xiàng)目
<template>
  <div id="singleVue"></div>
</template>
  1. 微前端子應(yīng)用注冊(cè)

子應(yīng)用打包成umd包,通過script加載,再使用single-spa的registerApplication api進(jìn)行注冊(cè)應(yīng)用,最終調(diào)用start方法啟動(dòng)子項(xiàng)目

// appConfig
const apps = [{
  host: 'http://localhost:9001',
  projectName: 'singleVue',
  activeWhen: location => location.pathname.startsWith('/vue'),
  bundle: 'app'
}]

export default apps
import { registerApplication, start } from 'single-spa'; //導(dǎo)入single-spa
import axios from 'axios'
import AppConfig from './appConfig'

/**
 * @name 加載異步j(luò)s
 * @description 一個(gè)promise同步方法。可以代替創(chuàng)建一個(gè)script標(biāo)簽,然后加載服務(wù)
 * @param {*} url 
 * @returns 
 */
const runScript = async (url) => {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = url;
    script.onload = () => {
      resolve()
    };
    script.onerror = (err) => {
      console.log(err)
      reject()
    };
    const firstScript = document.getElementsByTagName('script')[0];
    firstScript.parentNode.insertBefore(script, firstScript);
  });
};

const isObject = (obj) => {
  return Object.prototype.toString.call(obj) === '[object Object]'
}

/**
 * 加載子應(yīng)用
 * @param {*} host 
 * @param {*} globalVar 
 * @returns 
 */
const loadApp = (host, globalVar, bundle) => {
  return async () => {
    await getManifest(`${host}/asset-manifest.json`, bundle, host)
    return window[globalVar]
  }
}

/**
 * @description 加載子應(yīng)用
 * @param {*} url stats-webpack-plugin或者webpack-manifest-plugin插件生成的manifest文件
 * @param {*} bundle
 * @param {*} host 子應(yīng)用host+port
 */
const getManifest = async (url, bundle, host) => {
  const { data } = await axios.get(url);
  const { entrypoints } = data;
  let assets = []
  if (Array.isArray(entrypoints)) {
    assets = entrypoints
  } else {
    assets = entrypoints[bundle].assets
    assets = assets.map(obj => {
      if (isObject(obj)) {
        return obj.name
      }
      return obj
    })
  }

  for (let i = 0; i < assets.length; i++) {
    await runScript(`${host}/${assets[i]}`)
  }
}

AppConfig.forEach(app => {
  // 注冊(cè)微服務(wù)(子應(yīng)用)
  registerApplication({
    name: app.projectName,
    app: loadApp(app.host, app.projectName, app.bundle), // 子應(yīng)用為umd包,掛載在window下
    activeWhen: app.activeWhen, // 當(dāng)url匹配時(shí)展示子應(yīng)用
    customProps: app.customProps
  })
})

start(); // 啟動(dòng)

Vue子項(xiàng)目改造

Vue2.0

import Vue from 'vue'
import App from './App.vue'
import singleSpaVue from "single-spa-vue";

Vue.config.productionTip = false
// el 為子項(xiàng)目待掛載到父項(xiàng)目的DOM節(jié)點(diǎn)
const vueOptions = {
  el: "#singleVue2",
  render: h => h(App)
};

// 主應(yīng)用注冊(cè)成功后會(huì)在window下掛載singleSpaNavigate方法
// 為了獨(dú)立運(yùn)行,避免子項(xiàng)目頁面為空,
// 判斷如果不在微前端環(huán)境下進(jìn)行獨(dú)立渲染html
if (!window.singleSpaNavigate) {
  new Vue({
    render: h => h(App),
  }).$mount('#app')
}

const vueLifecycles = singleSpaVue({
  Vue,
  appOptions: vueOptions,
  handleInstance(app, props) {
    Vue.prototype.$eventBus = props.EventBus
  }
});

export const bootstrap = vueLifecycles.bootstrap; // 啟動(dòng)時(shí)
export const mount = vueLifecycles.mount; // 掛載時(shí)
export const unmount = vueLifecycles.unmount; // 卸載時(shí)

export default vueLifecycles;

Vue3.0

import { h, createApp } from 'vue'
import singleSpaVue from 'single-spa-vue'

import App from './App.vue'
import router from './router'

const appOptions = {
  el: '#singleVue', // 若提供el屬性,則掛載在el上,否則是,single-spa-application:${name}上,name為基座項(xiàng)目注冊(cè)子應(yīng)用設(shè)置的name
  render() {
    return h(App, {
      // single-spa props are available on the "this" object. Forward them to your component as needed.
      // https://single-spa.js.org/docs/building-applications#lifecycle-props
      // if you uncomment these, remember to add matching prop definitions for them in your App.vue file.
      /*
      name: this.name,
      mountParcel: this.mountParcel,
      singleSpa: this.singleSpa,
      */
      name: this.name,
      singleSpa: this.singleSpa,
      EventBus: this.EventBus,
    })
  },
}

if (!window.singleSpaNavigate) {
  createApp(App).use(router).mount('#app')
}

const vueLifecycles = singleSpaVue({
  createApp,
  appOptions,
  handleInstance(app) {
    app.use(router)
  },
})

export const bootstrap = [vueLifecycles.bootstrap]

export const mount = [vueLifecycles.mount]
export const unmount = [vueLifecycles.unmount]

export default vueLifecycles

修改vue.config.js

const StatsPlugin = require('stats-webpack-plugin')
const projectName = 'singleVue'
module.exports = {
  publicPath: '//localhost:9001',
  css: {
    extract: false
  },
  configureWebpack: {
    output: {
      library: {
        name: projectName, // 導(dǎo)出名稱
        type: 'umd' // 掛載目標(biāo),window.singleVue
      }
    },
    devServer: {
      port: '9001',
      headers: {
        'Access-Control-Allow-Origin': '*'
      },
      allowedHosts: 'all'
    },
    plugins: [
      new StatsPlugin('asset-manifest.json', {
        chunkModules: false,
        entryPoints: true,
        source: false,
        chunks:false,
        modules: false,
        assets: false,
        children: false,
        exclude: [/node_modules/]
      })
    ]
  },
}

React子項(xiàng)目改造

當(dāng)前改造基于React18.1,項(xiàng)目使用create-react-app創(chuàng)建

import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from "react-router-dom";
import { Provider } from 'react-redux'
import store from './store'
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import singleSpaReact from 'single-spa-react';

function rootComponent () {
  return (
    <React.StrictMode>
      <BrowserRouter>
        <Provider store={store}>
          <App />
        </Provider>
      </BrowserRouter>
    </React.StrictMode>
  )
}

if (!window.singleSpaNavigate) {
  const root = ReactDOM.createRoot(document.getElementById('root'));
  root.render(rootComponent());
}

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

const lifecycles = singleSpaReact({
  React,
  ReactDOM,
  rootComponent: rootComponent,
  errorBoundary(err, info, props) {
    // Customize the root error boundary for your microfrontend here.
    return null;
  },
  renderType: 'createRoot',
  domElementGetter: () => document.getElementById('singleReact')
})

export const bootstrap = [lifecycles.bootstrap]
export const mount = [lifecycles.mount]
export const unmount = [lifecycles.unmount]

// export const { bootstrap, mount, unmount } = lifecycles;

修改webpack配置,使用react-app-rewired,customize-cra修改配置

const { override, addWebpackPlugin, overrideDevServer } = require('customize-cra')
const StatsPlugin = require('stats-webpack-plugin')
const projectName = 'singleReact'
const customizePlugin = () => config => {
  config.output.publicPath = 'http://localhost:9003/'
  config.output.library = projectName
  config.output.libraryTarget = 'umd'
  return config
}

module.exports = {
  webpack: override(
    addWebpackPlugin(
      new StatsPlugin('asset-manifest.json', {
        chunkModules: false,
        entryPoints: true,
        source: false,
        chunks: false,
        modules: false,
        assets: false,
        children: false,
        exclude: [/node_modules/]
      })
    ),
    customizePlugin()
  ),
  devServer: overrideDevServer(
    config => {
      config.port = '9003'
      config.headers = config.headers || {}
      config.headers['Access-Control-Allow-Origin'] = '*'
      return config
    }
  )
}

基座項(xiàng)目與子項(xiàng)目的通信

single-spa官網(wǎng)推薦了兩種方式,一種是rxjs,另一種是使用自定義Event的方式。目前我采用了rxjs,實(shí)現(xiàn)類似EventBus的方式來通信。

import { ReplaySubject, filter, map } from 'rxjs'
class EventBus {
  constructor() {
    this.subject$ = new ReplaySubject()
  }
  emit(event) {
    this.subject$.next(event)
  }
  on(eventName, action) {
    return this.subject$.pipe(
      filter(e => e.name === eventName),
      map((e) => e.data)
    ).subscribe(action)
  }
}

export default EventBus

使用方式

// 下發(fā)消息
EventBus.emit({name: 'msgFromRoot', data: 'vue3 root msg'})

// 接收消息
EventBus?.value?.on('msgFromRoot', data => {
  console.log('vue:', data)
})

樣式隔離

可以通過postcss-selector-namespace或者postcss-prefix-selector插件來為所有樣式添加前綴。

項(xiàng)目地址

完整源碼請(qǐng)查看microfront

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容