前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >微前端——single-Spa

微前端——single-Spa

原创
作者头像
maureen
发布2022-10-06 20:15:41
3.6K0
发布2022-10-06 20:15:41
举报

一、概念

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.png
yarn start.png

yarn start:standalone,单独运行

yarn start:standalone.png
yarn 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 在一定程度上来说已经可以帮我们实现微前端了,但是实现的部分也很基础,还有很多问题需要解决。

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

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

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、概念
  • 二、SystemJs
    • 1、概念
      • 2、快速理解
        • 3、在single-spa中的应用
        • 三、快速上手
          • 1、single-spa脚手架
            • 2、手动配置
              • 2.1 创建好基座应用和子应用
              • 2.2 通过system接入子应用
              • 2.3 通过umd接入子应用
          • 四、最后
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
          http://www.vxiaotou.com