single-spa微前端项目落地

前言

    由于公司当前项目过于臃肿,打包速度越来越慢,同时在每次代码合并时,会出现非常多的冲突。因此,希望找到一种方式,来减小项目体积,又不影响现有代码的方式。在寻找过程中,发现微前端是一种很不错的方式,技术无关,同时可以分开部署,简直完美。

    目前主流的微前端方式,主要有iframe,single-spaqiankunmicro-app以及webpack5的module ferderation等。鉴于我们当前项目是以webpack4为主,首先排除了module ferderation。micro-app是京东开源的微前端框架,基于shadowdom实现,shadowdom容易出现一些问题,如iconfront显示问题,因此跳过。qiankun是蚂蚁集团基于single-spa进行的封装,但基于更喜欢自己封装,遂最终选择了single-spa。

single-spa

single-spa实现原理:首先对微前端路由进行注册,使用single-spa充当微前端加载器,并做为项目单一入口来接受全部页面URL的访问,根据页面URL与微前端的匹配关系,选择加载对应的微前端模块,再由该微前端模块进行路由响应URL,即微前端模块中路由找到相应的组件,渲染页面内容。

single-spa实现过程

  1. 基座项目

基于vue的基座项目,使用vue-cli创建基座项目

vue create micro-front-cli-root-config
  • 首先在dom创建节点挂载子项目,子项目注册后即可挂载在基座项目
<template>
  <div id="singleVue"></div>
</template>
  1. 微前端子应用注册

子应用打包成umd包,通过script加载,再使用single-spa的registerApplication api进行注册应用,最终调用start方法启动子项目

// 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'; //导入single-spa
import axios from 'axios'
import AppConfig from './appConfig'

/**
 * @name 加载异步js
 * @description 一个promise同步方法。可以代替创建一个script标签,然后加载服务
 * @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]'
}

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

/**
 * @description 加载子应用
 * @param {*} url stats-webpack-plugin或者webpack-manifest-plugin插件生成的manifest文件
 * @param {*} bundle
 * @param {*} host 子应用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 => {
  // 注册微服务(子应用)
  registerApplication({
    name: app.projectName,
    app: loadApp(app.host, app.projectName, app.bundle), // 子应用为umd包,挂载在window下
    activeWhen: app.activeWhen, // 当url匹配时展示子应用
    customProps: app.customProps
  })
})

start(); // 启动

Vue子项目改造

Vue2.0

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

Vue.config.productionTip = false
// el 为子项目待挂载到父项目的DOM节点
const vueOptions = {
  el: "#singleVue2",
  render: h => h(App)
};

// 主应用注册成功后会在window下挂载singleSpaNavigate方法
// 为了独立运行,避免子项目页面为空,
// 判断如果不在微前端环境下进行独立渲染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; // 启动时
export const mount = vueLifecycles.mount; // 挂载时
export const unmount = vueLifecycles.unmount; // 卸载时

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为基座项目注册子应用设置的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, // 导出名称
        type: 'umd' // 挂载目标,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子项目改造

当前改造基于React18.1,项目使用create-react-app创建

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
    }
  )
}

基座项目与子项目的通信

single-spa官网推荐了两种方式,一种是rxjs,另一种是使用自定义Event的方式。目前我采用了rxjs,实现类似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

使用方式

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

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

样式隔离

可以通过postcss-selector-namespace或者postcss-prefix-selector插件来为所有样式添加前缀。

项目地址

完整源码请查看microfront

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 195,898评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,401评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,058评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,539评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,382评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,319评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,706评论 3 386
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,370评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,664评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,715评论 2 312
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,476评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,326评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,730评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,003评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,275评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,683评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,877评论 2 335

推荐阅读更多精彩内容