一、概念
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导入一样需要声明对照关系,另一部分是注册模块
代码语言:txt复制(1)在es的写法通常是这样 'import 变量 from 位置' 直接使用变量
(2)在'systemjs' 中是 System.import(),引入的包中会注册模块,System.register("注册变量名",function(){}),这里的模块需要在systemjs-importmap中声明,如果webpack.config.js中没有配置externals,这里就会注册一个空数组
<!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 &&
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
这里会让选择类型,第一个中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 && 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:standalone,单独运行 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-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,开箱即用、接入简单,更适合真正的运用在项目中。