微前端——single-Spa

2022-10-06 20:15:41 浏览数 (1)

一、概念

2018年,single-spa诞生了,single-spa是一个小于5kb(gzip)npm包,用于协调微前端的挂载和卸载。只做两件事: 提供生命周期,并负责调度子应用的生命周期。挟持 url 变化,url 变化时匹配对应子应用,并执行生命周期流程。

用于前端微服务化的JavaScript前端解决方案 (本身没有处理样式隔离、js执行隔离) ,实现了路由劫持和应用加载。

特点:

(1)在同一页面上使用多个框架而无需刷新页面

(2)独立部署

(3)使用新框架编写代码,无需重写现有应用程序

(4)延迟加载代码以改善初始加载时间

(5)本身没有处理样式隔离、js执行隔离,共用同一个window

single-spa官方文档:https://zh-hans.single-spa.js.org/

二、SystemJs

1、概念

SystemJs是一个通用的模块加载器,他能在浏览器和node环境上动态加载模块,微前端的核心就是加载子应用,因此将子应用打包成模块,在浏览器中通过SystemJs来加载模块。

缺点:版本兼容性差,对开发者体验不好

2、快速理解

System.js拆分成两部分,一部分是导入文件“systemjs-importmap”,这里和我们使用es导入一样需要声明对照关系,另一部分是注册模块

(1)在es的写法通常是这样 'import 变量 from 位置' 直接使用变量

(2)在'systemjs' 中是 System.import(),引入的包中会注册模块,System.register("注册变量名",function(){}),这里的模块需要在systemjs-importmap中声明,如果webpack.config.js中没有配置externals,这里就会注册一个空数组

代码语言:txt复制
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script type="systemjs-importmap">
      {
        "imports": {
          "react": "https://cdn.bootcdn.net/ajax/libs/react/17.0.2/umd/react.production.min.js",
          "react-dom": "https://cdn.bootcdn.net/ajax/libs/react-dom/17.0.2/umd/react-dom.production.min.js"
        }
      }
    </script>
    <div id="root"></div>
    <script src="https://cdn.bootcdn.net/ajax/libs/systemjs/6.10.1/system.min.js"></script>
    <script>
      // 项目打包后,生成index.js,打包配置中将react和react-dom提取出来了
      // 依赖前置,引入index.js时
      // index.js里面会注册通过externals提取出的模块,ystem.register(["react","react-dom"], function(__WEBPACK_DYNAMIC_EXPORT__, __system_context__) {})
      // 因此需先加载react和react-dom
      System.import("./index.js");
    </script>
  </body>
</html>

项目打包后,输出system模块,并提取公共模块

代码语言:txt复制
const path = require("path");
module.exports = (env) => {
  return {
    mode: "development",
    output: {
      filename: "index.js",
      path: path.resolve(__dirname, "dist"),
      // 打包格式,system模块
      libraryTarget: env.production ? "system" : "",
    },
    module: {
      rules: [
        {
          test: /.js$/,
          use: {
            loader: "babel-loader",
          },
          exclude: /node_modules/,
        },
      ],
    },
    plugins: [
      !env.production &amp;&amp;
        new HtmlWebpackPlugin({
          template: "./public/index.html",
        }),
    ].filter(Boolean),
    externals: env.production ? ["react", "react-dom"] : [],
  };
};

3、在single-spa中的应用

在 single-spa的使用过程中,我们需要用importmap在根项目中引入所有的模块文件和子项目,从而在其余项目中可以进行模块的引用,我们开发者需要做的,就是把模块文件打包,然后通过 importmap引入,实现子模块的引入。

在使用single-spa时,不必使用SystemJS,不过为了能够独立部署各应用,很多示例和教程会推荐使用SystemJS。

代码语言:txt复制
<!-- 项目启动后,会去找对应端口下的文件 -->
  <script type="systemjs-importmap">
    {
      "imports": {
        "@single-spa/root-config": "//localhost:9000/single-spa-root-config.js",
        "@single-spa/vue-app": "//localhost:8080/js/app.js",
        "@single-spa/react-app": "//localhost:8081/single-spa-react-app.js"
      }
    }
  </script>

三、快速上手

1、single-spa脚手架

(1)全局下载create-single-spa

npm i create-single-spa -g

(2)创建项目

create-single-spa 应用名 创建项目的类型.jpg创建项目的类型.jpg

这里会让选择类型,第一个中application就是应用,parcel不受路由控制,相当于公共组件,多个应用可以引入,实现组件的共享;第二个是公共的模块,主要是一些工具方法;第三个是基座应用;根据当前创建的类型选择即可。

(3)基座应用

生成的目录结构:

代码语言:txt复制
├─ src                      
│  ├─ index.ejs             
│  └─ *-root-config.js  
├─ package-lock.json        
├─ package.json             
└─ webpack.config.js

index.ejs,主要引入system.js,并在importmap中配置好公共模块地址和子应用地址,做到按需导入,通过System.import('')引入基座。

代码语言:txt复制
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Root Config</title>
  <!-- async/await 解析包-->
  <script src="https://cdn.jsdelivr.net/npm/regenerator-runtime@0.13.7/runtime.min.js"></script>
 
  <meta http-equiv="Content-Security-Policy" content="default-src 'self' https: localhost:*; script-src 'unsafe-inline' 'unsafe-eval' https: localhost:*; connect-src https: localhost:* ws://localhost:*; style-src 'unsafe-inline' https:; object-src 'none';">
  <meta name="importmap-type" content="systemjs-importmap" />
  
  <!-- 
    single-spa:帮助挂载应用、切换应用,
    react 和 react-dom打包时会自动抽取,react-router-dom需要单独在externals中抽取
   -->
  <script type="systemjs-importmap">
    {
      "imports": {
        "single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js",
        "react":"https://cdn.bootcdn.net/ajax/libs/react/17.0.2/umd/react.production.min.js",
        "react-dom":"https://cdn.bootcdn.net/ajax/libs/react-dom/17.0.2/umd/react-dom.production.min.js",
        "react-router-dom":"https://cdn.bootcdn.net/ajax/libs/react-router-dom/5.2.0/react-router-dom.min.js"
      }
    }
  </script>
  <!-- 预加载 -->
  <link rel="preload" href="https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js" as="script">

  <% if (isLocal) { %>
    <!-- 项目启动后,会去找对应端口下的文件 -->
  <script type="systemjs-importmap">
    {
      "imports": {
        "@single-spa/root-config": "//localhost:9000/single-spa-root-config.js",
        "@single-spa/vue-app": "//localhost:8080/js/app.js",
        "@single-spa/react-app": "//localhost:8081/single-spa-react-app.js"
      }
    }
  </script>
  <% } %>

  <script src="https://cdn.jsdelivr.net/npm/import-map-overrides@2.2.0/dist/import-map-overrides.js"></script>
  <!-- 本地加载未压缩的,否则加载压缩后的 -->
  <% if (isLocal) { %>
  <script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/system.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/extras/amd.js"></script>
  <% } else { %>
  <script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/system.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/extras/amd.min.js"></script>
  <% } %>
</head>
<body>
  <noscript>
    You need to enable JavaScript to run this app.
  </noscript>
  <main></main>
  <script>
    // 引入基座
    System.import('@single-spa/root-config');
  </script>
  <import-map-overrides-full show-when-local-storage="devtools" dev-libs></import-map-overrides-full>
</body>
</html>

*-root-config.js,主要配置文件,注册子应用,并启动

代码语言:txt复制
import { registerApplication, start } from "single-spa";

// 注册应用--默认的welcome
registerApplication({
  name: "@single-spa/welcome",  // 应用名
  app: () =>                    // 当路径匹配到时,执行该方法
    System.import(              // 加载了远程的模块,这个模块会暴露三个钩子函数
      "https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js"
    ),
  activeWhen: location => location.pathname === "/",// 激活时机
});

// 注册vue子应用
registerApplication({
  name: "@single-spa/vue-app",  // 应用名
  app: () =>                    // 当路径匹配到时,执行该方法
    System.import(              // 加载了在index.ejs中的importmap的"@single-spa/vue-app配置项
      "@single-spa/vue-app"
    ),
  activeWhen: ["/vue-app"],     // 以/vue-app开头的
  customProps: { app: 'vue' }  // 自定义传参
});

// 注册react子应用
registerApplication({
  name: "@single-spa/react-app",  // 应用名
  app: () =>                    // 当路径匹配到时,执行该方法
    System.import(              // 加载了在index.ejs中的importmap的@single-spa/react-app配置项
      "@single-spa/react-app"
    ),
  activeWhen: ["/react-app"],     // 以/react-app开头的
  customProps: { app: 'react' }  // 自定义传参
});

// 启动应用
start({
  urlRerouteOnly: true,  // 是否可以通过 history.pushState() 和 history.replaceState() 更改触发 single-spa 路由,默认false,不允许
});

webpack.config.js,webpack配置文件,这个文件主要导入了 "webpack-config-single-spa",一个可共享的、可定制的 webpack 配置,是已经帮忙做好的关于single-spa的webpack 文件。

代码语言:txt复制
const { merge } = require("webpack-merge");
const singleSpaDefaults = require("webpack-config-single-spa");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = (webpackConfigEnv, argv) => {
  // 在importmap中引入时为:@single-spa/root-config
  const orgName = "single-spa";  // 组织名
  const defaultConfig = singleSpaDefaults({  // @single-spa/root-config
    orgName,
    projectName: "root-config",   // 项目名
    webpackConfigEnv, 
    argv,
    disableHtmlGeneration: true,  // 因为下面配置了HtmlWebpackPlugin,所以为true,默认为false,禁用HtmlWebpackPlugin
  });

  return merge(defaultConfig, {
    // modify the webpack config however you'd like to by adding to this object
    plugins: [
      new HtmlWebpackPlugin({
        inject: false,
        template: "src/index.ejs",
        templateParameters: {
          isLocal: webpackConfigEnv &amp;&amp; webpackConfigEnv.isLocal,
          orgName,
        },
      }),
    ],
  });
};

(4)vue子应用

main.js中,导出三个钩子函数

代码语言:txt复制
import { h, createApp } from 'vue';
import singleSpaVue from 'single-spa-vue';

import App from './App.vue';

// 利用了vue-cli-single-spa-plugin插件改写
const vueLifecycles = singleSpaVue({
  createApp,
  appOptions: {
    render() {
      // 将接收到的参数传给了App组件
      return h(App, {
        app: this.app   
      });
    },
  },
});

// 导出三个钩子函数,让基座拿到
export const bootstrap = vueLifecycles.bootstrap;
export const mount = vueLifecycles.mount;
export const unmount = vueLifecycles.unmount;

路由中,需要设置路由前缀,以便对应父应用中子应用的激活方式

代码语言:txt复制
import {createRouter, createWebHistory} from 'vue-router'
import Home from '../views/Home.vue'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('../views/About.vue')
  }
]

const router = createRouter({
  // 路由前缀
  history: createWebHistory('/vue-app'),
  routes
})

export default router

可以在vue.config.js中修改运行端口

代码语言:txt复制
// 修改运行端口
module.exports = {
    devServer: {
        port: 8080
    }
}

(5)react子应用

入口js文件

代码语言:txt复制
import React from "react";
import ReactDOM from "react-dom";
import singleSpaReact from "single-spa-react";
import Root from "./root.component";

const lifecycles = singleSpaReact({
  React,                              // 主React对象
  ReactDOM,                           // 主ReactDOMbject
  rootComponent: Root,                // 将被渲染的顶层React组件
  errorBoundary(err, info, props) {   // 错误边界
    return null;
  },
});

// 导出三个钩子函数
export const { bootstrap, mount, unmount } = lifecycles;

在webpack.config.js中将react-router-dom手动抽取出来,react和react-dom会自动抽取

代码语言:txt复制
const { merge } = require("webpack-merge");
const singleSpaDefaults = require("webpack-config-single-spa-react");

module.exports = (webpackConfigEnv, argv) => {
  const defaultConfig = singleSpaDefaults({
    orgName: "single-spa",
    projectName: "react-app",
    webpackConfigEnv,
    argv,
  });

  // 合并singlespa和自定义的配置
  return merge(defaultConfig, {
    // 抽取react-router-dom
    externals: ['react-router-dom']
  });
};

package.json中修改启动端口

代码语言:txt复制
"scripts": {
    "start": "webpack serve --port 8081",
    "start:standalone": "webpack serve --env standalone",
    "build": "concurrently npm:build:*"
  },

由于子应用都是经过single-spa改造过的,因此运行起来有些不同

直接运行yarn start,会提示微前端不在这,需要到主应用的端口访问 yarn start.pngyarn start.png

yarn start:standalone,单独运行 yarn start:standalone.pngyarn start:standalone.png

2、手动配置

2.1 创建好基座应用和子应用

2.2 通过system接入子应用

(1)改造基座

下载single-spa

代码语言:txt复制
npm i single-spa

index.html,引入systemjs,importmap中配置上子应用地址

代码语言:txt复制
<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title><%= htmlWebpackPlugin.options.title %></title>
    <meta name="importmap-type" content="systemjs-importmap" />
    <script type="systemjs-importmap">
      {
        "imports": {
          "child_vue":"http://localhost:8081/js/app.js"
        }
      }
    </script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/system.min.js"></script>
  </head>
  <body>
    <noscript>
      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="app"></div>
  </body>
</html>

main.js,注册子应用

代码语言:txt复制
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
// registerApplication注册应用,start开启应用
import {registerApplication,start} from 'single-spa'

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

// 注册vue项目
registerApplication(
  'child_vue',
  () => window.System.import('child_vue'),
  location => location.pathname.startsWith('/child_vue'),
  {
    appName:'child_vue啦啦'
  }
)

// 启动子应用
start()

(2)改造子应用

下载对应的包装器,如single-spa-vue,下载几个包:systemjs-webpack-interop、vue-cli-plugin-single-spa

代码语言:txt复制
npm i single-spa-vue systemjs-webpack-interop
npm i vue-cli-plugin-single-spa -D

router/index.js,跟前面一样的,添加路由前缀

main.js,引入single-spa-vue包装器,导出生命周期钩子函数

代码语言:txt复制
import './set-public-path';
import Vue from 'vue';
import singleSpaVue from 'single-spa-vue';

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

Vue.config.productionTip = false;

const vueLifecycles = singleSpaVue({
  Vue,
  appOptions: {
    el:"#child_vue",  // 父组件中放子应用的标签
    router,
    render(h) {
      return h(App, {
        props: {
          // 基座应用传的值
          appName: this.appName
        },
      });
    },
  },
});

// 导出三个生命周期
export const bootstrap = vueLifecycles.bootstrap;
export const mount = vueLifecycles.mount;
export const unmount = vueLifecycles.unmount;

set-public-path.js,引入systemjs-webpack-interop,它是一个npm包,它导出的函数可以帮你创建一个webpack包,这个包可以被systemjs作为浏览器内模块使用。调用setPublicPath设置公共路径。

代码语言:txt复制
import { setPublicPath } from 'systemjs-webpack-interop';

// setPublicPath(systemjsModuleName, rootDirectoryLevel = 1)设置公共路径
// systemjsModuleName:systemjs模块的字符串名称。这个名称应该存在于导入映射中。
// rootDirectoryLevel:默认为1的整数,表示将使用哪个目录作为公共路径。使用计算公共路径,1表示“当前目录”,2表示“向上一个目录”
setPublicPath('child_vue', 2);

vue.config.js,配置端口,打包成system模块

代码语言:txt复制
module.exports = {
  devServer: {
    port: 8081
  },
  chainWebpack: (config) => {
    config.devServer.set('inline', false);
    config.devServer.set('hot', true);
    if (process.env.NODE_ENV !== 'production') {
      config.output.filename(`js/[name].js`);
    }
    // 打包成system模块
    config.output.libraryTarget('system');
    config.externals(['vue', 'vue-router']);
  },
  filenameHashing: false,
};

2.3 通过umd接入子应用

(1)改造基座

下载single-spa

代码语言:txt复制
npm i single-spa

main.js,注册子应用,通过动态创建script标签已引入子应用文件,并启动应用

代码语言:txt复制
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
// registerApplication注册应用,start开启应用
import {registerApplication,start} from 'single-spa'

Vue.config.productionTip = false

function loadUrl(url){
  return new Promise((resolve,reject) => {
    const el = document.createElement("script")
    el.src = url
    el.onload = resolve
    el.onerror = reject
    document.head.appendChild(el)
  })
}

// 注册子应用-vue
// registerApplication(要加载的组件的名字,要使用的方法且必须是个promise函数,什么时候加载组件默认有个location的参数,需要父子传的参数)
registerApplication("child_vue",async () => {
  // 要把子组件的包引进来,必须通过自定义标签,顺序必须如此
  await loadUrl("http://localhost:8081/js/chunk-vendors.js")
  await loadUrl("http://localhost:8081/js/app.js")
  return window.child_vue
},location => location.pathname.startsWith("/child_vue"),
{appName:'child_vue  umd包'}
)

// 启动子应用
start()

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

(2)改造子应用

router/index.js,如上面一样,添加路由前缀

main.js,注册子应用,导出生命周期钩子函数,接收主应用传来的参数

代码语言:txt复制
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import singleSpaVue from 'single-spa-vue'

Vue.config.productionTip = false

// 要被父应用加载,必须暴露三个接口 bootstrap mount unmount ,可以用协议直接生成
const vueLifecycles = singleSpaVue({
  Vue,
  appOptions: {
    el:"#child_vue",  // 父组件中放子应用的标签
    router,
    render(h) {
      return h(App, {
        props: {
          // 基座应用传的值
          appName: this.appName
        },
      });
    },
  },
});

// 若是父项目访问,则将路径定为子项目的路径
if(window.singleSpaNavigate){
  __webpack_public_path__ = "http://localhost:8081/"
}else{
  // 可以单独运行
  delete appOptions.el
  new Vue(appOptions).$mount("#app")
}
// 从singleSpa包装好的生命周期中导出接口
export const bootstrap = vueLifecycles.bootstrap
export const mount = vueLifecycles.mount
export const unmount = vueLifecycles.unmount

vue.config.js,配置应用打包出来的模块类型和应用运行端口号

代码语言:txt复制
module.exports = {
    configureWebpack:{
        output:{   // 打包成一个库
            library:"child_vue",
            // 打包完是一个umd模块,可以把导出的 bootstrap mount unmount 放到childApp里面,然后挂在window上
            libraryTarget:"umd"  
        },
        devServer:{
            port:8081
        }
    }
}

针对各个框架,single-spa提供了很多包装器,包装器可以把子应用进行包装,给子应用提供生命周期钩子,并将其导出。

  • single-spa-react
  • single-spa-vue
  • single-spa-angular
  • single-spa-angularjs
  • single-spa-cycle
  • single-spa-ember
  • single-spa-inferno
  • single-spa-preact
  • single-spa-svelte
  • single-spa-riot
  • single-spa-backbone
  • single-spa-dojo
  • single-spa-alpinejs

四、最后

Single-spa 在一定程度上来说已经可以帮我们实现微前端了,但是实现的部分也很基础,还有很多问题需要解决。

比如改造老项目,大部分的老项目并没有打包成一个 js,并且接入微前端也不是一次性全部拆分,可能是先拆出去一部分。将已有模块拆分成子项目,需要将子项目打包成systemjs 能够导入的 js,这需要对项目配置做一定的改变,但是systemjs的兼容性也不好。引入项目以后,还需要考虑到子项目对其他模块的影响,虽然我们可以制定规范,比如各子项目使用唯一地命名前缀等,但这种人为约定往往又是不那么靠谱,对于css,我们还可以在构建时使用一些工具自动添加前缀,这样可以比较靠谱的避免冲突;对于js来说,比较靠谱的方式可能就是人为制造沙箱,让子应用的js都运行在各自的沙箱中,但这实现起来相对就比较复杂了。

总的来说 single-spa 是一个非常基础的微前端框架,应用引入麻烦,很多微前端该有的功能他都没有,因此,在single-spa的基础上诞生了qiankun,开箱即用、接入简单,更适合真正的运用在项目中。

0 人点赞