初识 single-spa

325 阅读5分钟

做一件事,无论大小,倘无恒心,是很不好的。--鲁迅

下述demo代码收录在GitHub上,如有需求点击查看。

什么是微前端

一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。 各个前端应用还可以独立运行、独立开发、独立部署。详细介绍见Micro Frontends 官网

解决什么问题

将复杂项目拆分成多个子项目进行维护, 优点:

  • 技术栈多样性:不局限于一种技术栈,可以应用Vue、React等技术栈
  • 打包速度快:子项目单独打包,代码量变小,打包速度加快
  • 各个模块耦合性降低:拆分成子项目后模块之间的耦合性降低,上线风险降低
  • 代码复用性更高:拆分成独立的子项目复用性更高,复用成本也更低
  • 重构更便捷:拆分的独立子项目逻辑更清晰,重构成本更低 缺点:
  • 维护成本更高:拆分子项目越多,维护成本越高
  • 技术成本更高:选择适合的一种微前端解决方案

已存在的微前端解决方案

方案描述优点缺点
nginx路由转发通过Nginx配置反向代理来实现不同路径映射到不同应用,例如www.abc.com/app1对应app1,www.abc.com/app2对应app2,这种方案本身并不属于前端层面的改造,更多的是运维的配置简单,快速,易配置涉及到对应路由切换时,会触发浏览器刷新,影响体验
iframe嵌套父应用单独是一个页面,每个子应用嵌套一个iframe,父子通信可采用postMessage或者contentWindow方式实现简单,父子组件天生隔离样式、兼容性、登录鉴权
Web Components每个子应用需要采用纯Web Components技术编写组件,是一套全新的开发模式每个子应用拥有独立的script和css,也可单独部署改造成本高
组合式应用路由分发每个子应用独立构建和部署,运行时由父应用来进行路由管理,应用加载,启动,卸载,以及通信机制纯前端改造需要设计和开发,由于父子应用处于同一页面运行,需要解决子应用的样式冲突,变量对象污染,通信机制等技术点
组合式应用组件分发每个子应用独立构建部署纯前端改造,基于 webpack5 模块联邦机制同上

现存在的微前端框架

基于single-spa示例(GithubDemo地址)

几个关键的包

一个小例子 🌰

base主项目(技术栈:Vue)

main.js

import Vue from 'vue'
import App from './App.vue';
import { router , VueRouter} from './router';
import './single-spa.config.js';

Vue.config.productionTip = false;
Vue.use(VueRouter)

new Vue({
  render: h => h(App),
  router
}).$mount('#app')

single-spa.config.js

注意:通过.json文件动态加载子项目的js文件时,必须要等入口文件加载完毕再加载其他静态文件,不然会导致子项目抛出的生命周期挂载不到window上。因为入口文件没有完全执行,所以export的钩子没有挂载上。

import * as singleSpa from 'single-spa/lib/umd/single-spa.dev'; //导入single-spa
import axios from 'axios';
/*
 * runScript:一个promise同步方法。可以代替创建一个script标签,然后加载服务
 **/
const runScript = async (url) => {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.setAttribute('type', 'text/javascript');
    script.src = url;
    script.onload = () => {
      resolve(url);
    };
    script.onerror = reject;
    const firstScript = document.body.getElementsByTagName('script')[0];
    document.body.insertBefore(script,firstScript);
  });
};
​
/*
 * getManifest:远程加载manifest.json 文件,解析需要加载的js
 * url: manifest.json 链接
 * bundle:entry名称
 * */
const getManifest = (url, bundle) =>
  new Promise((resolve) => {
    axios.get(url).then( async (res) => {
      // 一定是异步的
      const { data } = res || {};
      const { entrypoints, publicPath } = data;
      const assets = entrypoints[bundle].assets;
      for (let i = 0; i < assets.length; i++) {
        const name =  assets[i]?.name ||  assets[i];
        // 等完全挂载完才能挂载下一个js文件
        await runScript(publicPath + name).then(() => {
          if (i === assets.length - 1) {
            resolve();
          }
        });
      }
    });
  });
singleSpa.registerApplication(
  //注册微前端服务
  'slaveReact',
  async () => {
    await getManifest('//127.0.0.1:9990/asset-stats.json', 'main');
    return window.slaveReact;
  },
  (location) => {
    // addShadowDom();
    return location.pathname.startsWith('/react');
  }, // 配置微前端模块前缀
  {}
);
singleSpa.registerApplication(
  //注册微前端服务
  'slaveVue',
  async () => {
    await getManifest(
      '//127.0.0.1:9989/asset-stats.json',
      'app'
    );
    return window.slaveVue;
  },
  (location) => location.pathname.startsWith('/vue'), // 配置微前端模块前缀
  {
    components: {},
    parcels: {},
  }
);
singleSpa.start(); // 启动window.addEventListener('single-spa:before-routing-event', () => {
  console.log('before-routing-event');
});
window.addEventListener('single-spa:app-change', () => {
});
​

slave-vue-demo

main.js 入口文件导出生命周期钩子

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


Vue.config.productionTip = false

const vueLifecycles = singleSpaVue({
  Vue,
  appOptions: {
      render(h) {
          return h(App);
      },
      el: '#slave'
  },
});


export const bootstrap = vueLifecycles.bootstrap;
export const mount = vueLifecycles.mount;
export const unmount = vueLifecycles.unmount;

export default vueLifecycles;

vue.config.js

var StatsPlugin = require('stats-webpack-plugin');

module.exports = {
  devServer: {
    port: 9989,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Headers': '*',
    },
  },
  publicPath: 'http://127.0.0.1:9989',
  configureWebpack: {
    mode: 'development',
    output: {
      library: 'slaveVue',  // 挂载名称
      libraryTarget: 'umd', // 挂载类型
      jsonpFunction: `webpackJsonp_slaveVue`
    },
    plugins: [
      // 生成引入静态文件的文件
      new StatsPlugin('asset-stats.json', {
        chunkModules: true,
        exclude: [/node_modules/],
      }),
    ],
  },
};

slave-react-demo

index.js

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import singleSpaReact from 'single-spa-react';

const lifecycles = singleSpaReact({
  React,
  ReactDOM,
  rootComponent: () => <App />,
  errorBoundary(err, info, props) {
      return <div> Error </div>;
  },
  domElementGetter: () => document.getElementById('slave'),
});
// 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();


export const bootstrap = lifecycles.bootstrap;
export const mount = lifecycles.mount;
export const unmount = lifecycles.unmount; 

由于此项目是create-react-app脚手架生成的,要生成所需引入的静态文件目录文件也需在webpack配置中引入stats-webpack-plugin这个插件,所以使用 npm run eject生成了项目中的webpack配置,具体修改可以参考 https://github.com/jdkwky/single-demo/blob/master/packages/react-demo/config/webpack.config.js配置。