当学习成为了习惯,知识也就变成了常识。感谢各位的 点赞、收藏和评论。
新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn
文章已收录到 github,欢迎 Watch 和 Star。
从基本使用 -> 部署 -> 框架源码分析 -> 手写框架,带你全方位刨析 single-spa 框架
目的
会使用single-spa
开发项目,然后打包部署上线
刨析single-spa
的源码原理
手写一个自己的single-spa
框架
过程
编写示例项目
打包部署
框架源码解读
手写框架
关于微前端
的介绍这里就不再赘述了,网上有很多的文章,本文的重点在于刨析微前端框架single-spa
的实现原理。
single-spa
是一个很好的微前端基础框架,qiankun
框架就是基于single-spa
来实现的,在single-spa
的基础上做了一层封装,也解决了single-spa
的一些缺陷。
因为single-spa
是一个基础的微前端框架,了解了它的实现原理,再去看其它的微前端框架,就会非常容易了。
先熟悉基本使用,熟悉常用的API,可通过示例项目 + 官网相结合来达成
如果基础比较好,可以先读后面的手写 single-spa 框架
部分,再回来阅读源码,效果可能会更好
文章中涉及到的所有代码都在 github(示例项目 + single-spa
源码分析 + 手写single-spa
框架 + single-spa-vue
源码分析)
新建项目目录,接下来的所有代码都会在该目录中完成
mkdir micro-frontend && cd micro-frontend
示例代码都是通过vue
来编写的,当然也可以采用其它的,比如react
或者原生JS
等
vue create app1
按图选择,去除一切项目不需要的干扰项,后面一路回车,等待应用创建完毕
以下所有的操作都在项目根目录
/micro-frontend/app1
下完成
在项目根目录下新建vue.config.js
文件
const package = require('./package.json')
module.exports = {
// 告诉子应用在这个地址加载静态资源,否则会去基座应用的域名下加载
publicPath: '//localhost:8081',
// 开发服务器
devServer: {
port: 8081
},
configureWebpack: {
// 导出umd格式的包,在全局对象上挂载属性package.name,基座应用需要通过这个全局对象获取一些信息,比如子应用导出的生命周期函数
output: {
// library的值在所有子应用中需要唯一
library: package.name,
libraryTarget: 'umd'
}
}
}
single-spa-vue
npm i single-spa-vue -S
single-spa-vue
负责为vue
应用生成通用的生命周期钩子,在子应用注册到single-spa
的基座应用时需要用到
// /src/main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import singleSpaVue from 'single-spa-vue'
Vue.config.productionTip = false
const appOptions = {
el: '#microApp',
router,
render: h => h(App)
}
// 支持应用独立运行、部署,不依赖于基座应用
if (!window.singleSpaNavigate) {
delete appOptions.el
new Vue(appOptions).$mount('#app')
}
// 基于基座应用,导出生命周期函数
const vueLifecycle = singleSpaVue({
Vue,
appOptions
})
export function bootstrap (props) {
console.log('app1 bootstrap')
return vueLifecycle.bootstrap(() => {
})
}
export function mount (props) {
console.log('app1 mount')
return vueLifecycle.mount(() => {
})
}
export function unmount (props) {
console.log('app1 unmount')
return vueLifecycle.unmount(() => {
})
}
app1 home page
app1 about page
应用独立运行时的开发环境配置
NODE_ENV=development
VUE_APP_BASE_URL=/
作为子应用运行时的开发环境配置
NODE_ENV=development
VUE_APP_BASE_URL=/app1
作为子应用构建生产环境bundle
时的环境配置,但这里的NODE_ENV
为development
,而不是production
,是为了方便,这个方便其实single-spa
带来的弊端(js entry的弊端)
NODE_ENV=development
VUE_APP_BASE_URL=/app1
// /src/router/index.js
// ...
const router = new VueRouter({
mode: 'history',
// 通过环境变量来配置路由的 base url
base: process.env.VUE_APP_BASE_URL,
routes
})
// ...
package.json
中的script
{
"name": "app1",
// ...
"scripts": {
// 独立运行
"serve": "vue-cli-service serve",
// 作为子应用运行
"serve:micro": "vue-cli-service serve --mode micro",
// 构建子应用
"build": "vue-cli-service build --mode buildMicro"
},
// ...
}
npm run serve
当然下面的启动方式也可以,只不过会在pathname
的开头加了/app1
前缀
npm run serve:micro
npm run serve:micro
在/micro-frontend目录下新建子应用
app2,步骤同
app1,只需把过程中出现的'app1'字样改成'app2'即可,
vue.config.js中的
8081改成
8082`
这部分内容于
2020/08/30
添加,为什么后来添加这部分内容呢?是因为有同学希望增加一个react
项目的示例,他们在集成react
项目时遇到了一些困难,于是找时间就加了这部分内容;发现网上single-spa
集成react
的示例非常少,仅有的几个看了下也是对官网示例的抄写。
示例项目是基于react
脚手架cra
创建的,整个集成的过程中难点有两个:
webpack
的配置,这部分内容官网有提供
子应用入口的配置,单纯看官方文档的示例项目根本跑不起来,或者即使跑起来也有问题,react
和vue
的集成还不一样,react
需要在主项目的配置中也加一点东西,这部分官网配置没说,是通过single-spa-react
源码看出来的
接下来就开始吧,在/micro-frontend
目录下通过cra
脚手架新建子应用app3
create-react-app app3
以下所有操作都在/micro-frontend/app3
目录下进行
react-router-dom
、single-spa-react
npm i react-router-dom single-spa-react -S
打散项目的配置,方便更改webpack
的配置内容,当然通过react-app-rewired
覆写默认配置应该也是可以的,官网也有提到,不过我这里没试,采用的是直接打散配置
npm run eject
删掉optimization
部分,这部分配置和chunk
有关,有动态生成的异步chunk
存在,会导致主应用无法配置,因为chunk
的名字会变,其实这也是single-spa
的缺陷,或者说采用JS entry
的缺陷,JS entry
建议将所有内容都打成一个bundle
- app.js
更改entry
和output
部分
{
...
entry: [
paths.appIndexJs,
].filter(Boolean),
output: {
path: isEnvProduction ? paths.appBuild : undefined,
filename: 'js/app.js',
publicPath: '//localhost:3000',
jsonpFunction: `webpackJsonp${
appPackageJson.name}`,
library: 'app3',
libraryTarget: 'umd'
},
...
}
我这里将无关紧要的内容都删了,只留了/src/index.js
和/src/index.css
由于文章内容太多,字数超出限制,这部分代码就通过图片的形式来展示了,如果需要拷贝可去 github
body {
text-align: center;
}
npm run start
浏览器访问localhost:3000
在/micro-frontend
目录下新建基座应用,为了简洁明了,新建项目时选择的配置项和子应用一样;在本示例中基座应用采用了vue
来实现,用别的方式或者框架实现也可以,比如自己用webpack
构建一个项目。
以下操作都在
/micro-frontend/layout
目录下进行
single-spa
npm i single-spa -S
// src/main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import {
registerApplication, start } from 'single-spa'
Vue.config.productionTip = false
// 远程加载子应用
function createScript(url) {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = url
script.onload = resolve
script.onerror = reject
const firstScript = document.getElementsByTagName('script')[0]
firstScript.parentNode.insertBefore(script, firstScript)
})
}
// 记载函数,返回一个 promise
function loadApp(url, globalVar) {
// 支持远程加载子应用
return async () => {
await createScript(url + '/js/chunk-vendors.js')
await createScript(url + '/js/app.js')
// 这里的return很重要,需要从这个全局对象中拿到子应用暴露出来的生命周期函数
return window[globalVar]
}
}
// 子应用列表
const apps = [
{
// 子应用名称
name: 'app1',
// 子应用加载函数,是一个promise
app: loadApp('http://localhost:8081', 'app1'),
// 当路由满足条件时(返回true),激活(挂载)子应用
activeWhen: location => location.pathname.startsWith('/app1'),
// 传递给子应用的对象
customProps: {
}
},
{
name: 'app2',
app: loadApp('http://localhost:8082', 'app2'),
activeWhen: location => location.pathname.startsWith('/app2'),
customProps: {
}
},
{
// 子应用名称
name: 'app3',
// 子应用加载函数,是一个promise
app: loadApp('http://localhost:3000', 'app3'),
// 当路由满足条件时(返回true),激活(挂载)子应用
activeWhen: location => location.pathname.startsWith('/app3'),
// 传递给子应用的对象,这个很重要,该配置告诉react子应用自己的容器元素是什么,这块儿和vue子应用的集成不一样,官网并没有说这部分,或者我没找到,是通过看single-spa-react源码知道的
customProps: {
domElement: document.getElementById('microApp'),
// 添加 name 属性是为了兼容自己写的lyn-single-spa,原生的不需要,当然加了也不影响
name: 'app3'
}
}
]
// 注册子应用
for (let i = apps.length - 1; i >= 0; i--) {
registerApplication(apps[i])
}
new Vue({
router,
mounted() {
// 启动
start()
},
render: h => h(App)
}).$mount('#app')
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
const routes = []
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
export default router
npm run serve
终于看到了结果。
小技巧
有时候
single-spa
可能会报一些我们现在无法理解的错误,我们可能需要去做代码调试,阅读源码时碰到不理解的地方也需要编写示例
+ 单步调试
,但是默认的是已经打包压缩后的代码,不太方便做这些,大家可以在node_modules
目录找到single-spa
目录,把目录下的package.json
中的module
字段的值改为lib/single-spa.dev.js
,这是一个未压缩的bundle
,利于代码的阅读的调试,当然需要重启应用。
子应用也是一样类似的技巧,因为
single-spa-vue
就一个文件,可以直接拷贝出来放到项目的/src
目录下,将main.js
中的引入的single-spa-vue
改成当前目录即可。
在各个项目的根目录下分别执行
npm run build
可以将打包后的bundle
发布到nginx
服务器上,这个nginx
服务器可以是单独的服务器、或者虚拟机、亦或是docker
容器都行,这里采用serve
在本地模拟部署
如果你有条件部署到nginx
上,需要注意nginx
的代理配置
activeWhen
去挂载对应的子应用全局安装 serve
npm i serve -g
在各个项目的根目录下启动 serve
serve ./dist -p port
在浏览器访问基座应用的地址,发现得到和刚才一样的结果
整个阅读过程以示例项目为例,阅读源码时一定要多动手写注释、做笔记,遇到不理解的地方编写示例代码 +
console.log
+ 单步调试,切记不要只看不动手。
single-spa 源码阅读思维导图
这是我在阅读时整理的一个思维导图,源码中也写了大量的注释,大家可以参照着进行阅读。Ok !!这就开始吧
从源码目录中可以看到,single-spa
是使用rollup
来打包的,从rollup.config.js
中可以发现入口是single-spa.js
,
打开会发现里面导出了一大堆东西,有我们非常熟悉的各个方法,我们就从registerApplication
方法开始
single-spa/src/applications/apps.js
/**
* 注册应用,两种方式
* registerApplication('app1', loadApp(url), activeWhen('/app1'), customProps)
* registerApplication({
* name: 'app1',
* app: loadApp(url),
* activeWhen: activeWhen('/app1'),
* customProps: {}
* })
* @param {*} appNameOrConfig 应用名称或者应用配置对象
* @param {*} appOrLoadApp 应用的加载方法,是一个 promise
* @param {*} activeWhen 判断应用是否激活的一个方法,方法返回 true or false
* @param {*} customProps 传递给子应用的 props 对象
*/
export function registerApplication(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
) {
/**
* 格式化用户传递的应用配置参数
* registration = {
* name: 'app1',
* loadApp: 返回promise的函数,
* activeWhen: 返回boolean值的函数,
* customProps: {},
* }
*/
const registration = sanitizeArguments(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
);
// 判断应用是否重名
if (getAppNames().indexOf(registration.name) !== -1)
throw Error(
formatErrorMessage(
21,
__DEV__ &&
`There is already an app registered with name ${
registration.name}`,
registration.name
)
);
// 将各个应用的配置信息都存放到 apps 数组中
apps.push(
// 给每个应用增加一个内置属性
assign(
{
loadErrorTime: null,
// 最重要的,应用的状态
status: NOT_LOADED,
parcels: {
},
devtools: {
overlays: {
options: {
},
selectors: [],
},
},
},
registration
)
);
// 浏览器环境运行
if (isInBrowser) {
// https://zh-hans.single-spa.js.org/docs/api#ensurejquerysupport
// 如果页面中使用了jQuery,则给jQuery打patch
ensureJQuerySupport();
reroute();
}
}
single-spa/src/applications/apps.js
// 返回处理后的应用配置对象
function sanitizeArguments(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
) {
// 判断第一个参数是否为对象
const usingObjectAPI = typeof appNameOrConfig === "object";
// 初始化应用配置对象
const registration = {
name: null,
loadApp: null,
activeWhen: null,
customProps: null,
};
if (usingObjectAPI) {
// 注册应用的时候传递的参数是对象
validateRegisterWithConfig(appNameOrConfig);
registration.name = appNameOrConfig.name;
registration.loadApp = appNameOrConfig.app;
registration.activeWhen = appNameOrConfig.activeWhen;
registration.customProps = appNameOrConfig.customProps;
} else {
// 参数列表
validateRegisterWithArguments(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
);
registration.name = appNameOrConfig;
registration.loadApp = appOrLoadApp;
registration.activeWhen = activeWhen;
registration.customProps = customProps;
}
// 如果第二个参数不是一个函数,比如是一个包含已经生命周期的对象,则包装成一个返回 promise 的函数
registration.loadApp = sanitizeLoadApp(registration.loadApp);
// 如果用户没有提供 props 对象,则给一个默认的空对象
registration.customProps = sanitizeCustomProps(registration.customProps);
// 保证activeWhen是一个返回boolean值的函数
registration.activeWhen = sanitizeActiveWhen(registration.activeWhen);
// 返回处理后的应用配置对象
return registration;
}
single-spa/src/applications/apps.js
/**
* 验证应用配置对象的各个属性是否存在不合法的情况,存在则抛出错误
* @param {*} config = { name: 'app1', app: function, activeWhen: function, customProps: {} }
*/
export function validateRegisterWithConfig(config) {
// 异常判断,应用的配置对象不能是数组或者null
if (Array.isArray(config) || config === null)
throw Error(
formatErrorMessage(
39,
__DEV__ && "Configuration object can't be an Array or null!"
)
);
// 配置对象只能包括这四个key
const validKeys = ["name", "app", "activeWhen", "customProps"];
// 找到配置对象存在的无效的key
const invalidKeys = Object.keys(config).reduce(
(i