概述:从实现一个single-spa的前端微服务中
预备知识
上一篇文章:从0实现一个前端微服务(上)中讲到,single-spa
的原理就是,将子项目中的link/script
标签和<div id="app"></div>
插入到主项目,而这个操作的核心就是动态加载js
和css
。
动态加载js
我们使用的是system.js
,借助这个插件,我们只需要将子项目的app.js
暴露给它即可。
本文章基于GitHub上一个single-spa的demo修改,所以最好有研究过这个demo
,另外本文的基于最新的vue-cli4
开发。
single-spa-vue实现步骤
要实现的效果就是子项目独立开发部署,顺便还能被主项目集成。
新建导航主项目
vue-cli4
直接使用vue create nav
命令生成一个vue
项目。
需要注意的是,导航项目路由必须用 history 模式
- 修改
index.html
文件
<!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"> <link rel="icon" href="<%= BASE_URL %>favicon.ico"> <title>home-nav</title> <!-- 配置文件注意写成绝对路径:/开头,否则访问子项目的时候重定向的index.html,相对目录会出错 --> <script type="systemjs-importmap" src="/config/importmap.json"></script> <!-- 预请求single-spa,vue,vue-router文件 --> <link rel="preload" href="https://cdnjs.cloudflare.com/ajax/libs/single-spa/4.3.7/system/single-spa.min.js" as="script" crossorigin="anonymous" /> <link rel="preload" href="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js" as="script" crossorigin="anonymous" /> <link rel="preload" href="https://cdn.jsdelivr.net/npm/vue-router@3.0.7/dist/vue-router.min.js" as="script" crossorigin="anonymous" /> <!-- 引入system.js相关文件 --> <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/system.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/extras/amd.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/extras/named-exports.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.1.1/extras/use-default.min.js"></script> </head> <body> <script> (function() { System.import('single-spa').then(singleSpa => { singleSpa.registerApplication( 'appVueHistory', () => System.import('appVueHistory'), location => location.pathname.startsWith('/app-vue-history/') ) singleSpa.registerApplication( 'appVueHash', () => System.import('appVueHash'), location => location.pathname.startsWith('/app-vue-hash/') ) singleSpa.start(); }) })() </script> <div class="wrap"> <div class="nav-wrap"> <div id="app"></div> </div> <div class="single-spa-container"> <div id="single-spa-application:appVueHash"></div> <div id="single-spa-application:appVueHistory"></div> </div> </div> <style> .wrap{ display: flex; } .nav-wrap{ flex: 0 0 200px; } .single-spa-container{ width: 200px; flex-grow: 1; } </style> </body> </html>
- 子项目和公共文件url的配置文件
config/importmap.json
:
{ "imports": { "appVue": "http://localhost:7778/app.js", "appVueHistory": "http://localhost:7779/app.js", "single-spa": "https://cdnjs.cloudflare.com/ajax/libs/single-spa/4.3.7/system/single-spa.min.js", "vue": "https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js", "vue-router": "https://cdn.jsdelivr.net/npm/vue-router@3.0.7/dist/vue-router.min.js" } }
子项目改造
hash模式路由的vue项目
如果是新开发的项目,可以先用vue-cli4
生成一个vue
项目,路由使用的是hash
模式。
1. 安装插件(稍后会介绍其作用):
如果是老项目,需要分别安装一下三个插件:
npm install systemjs-webpack-interop -S npm install single-spa-vue -S npm install vue-cli-plugin-single-spa -D
如果是新项目,则可以使用以下命令:
vue add single-spa
注意:该命令会改写你的 main.js,老项目不要用这个命令
该命令做了四事件:
- (1) 安装
single-spa-vue
插件 - (2) 安装
systemjs-webpack-interop
插件,并生成set-public-path.js
- (3) 修改
main.js
- (4) 修改
webpack
配置(允许跨域,关闭热更新,去掉splitChunks
等)
2. 新增两个环境变量
由于single-spa
模式也有开发和生成环境,所以有4种环境:正常开发,single-spa
开发,正常打包,single-spa
打包。但是我们只需要两个环境变量文件即可区分开,分别在在根目录下新建环境变量文件:
.env.devSingleSpa
文件(区分正常开发和single-spa
模式开发):
NODE_ENV = development VUE_APP__ENV = singleSpa
.env.singleSpa
文件(区分正常打包和single-spa
模式打包):
NODE_ENV = production VUE_APP__ENV = singleSpa
3. 修改入口文件
single-spa
和正常开发模式不一样的地方仅仅在入口文件。其中入口文件中需要引入的插件(vuex
,vue-router
,axios
,element-ui
等)完全一样,不一样的地方在于,正常开发是new Vue(options)
,single-spa
则是调用singleSpaVue(Vue,options)
函数,并且将三个生命周期export
。
所以我将两种模式下公共的部分任然写在main.js
,并导出两种模式所需的配置对象:
import store from "./store"; import Vue from 'vue'; import App from './App.vue'; import router from './router'; const appOptions = { render: (h) => h(App), router, store, } Vue.config.productionTip = false; export default appOptions;
新增index.js
(正常模式入口文件) :
import appOptions from './main'; import './main'; import Vue from 'vue'; new Vue(appOptions).$mount('#app');
新增index.spa.js
(single-spa
模式入口文件) :
import './set-public-path' import singleSpaVue from 'single-spa-vue'; import appOptions from './main'; import './main'; import Vue from 'vue'; const vueLifecycles = singleSpaVue({ Vue, appOptions }); const { bootstrap, mount, unmount } = vueLifecycles; export { bootstrap, mount, unmount };
其中index.spa.js
里面的set-public-path.js
:
import { setPublicPath } from 'systemjs-webpack-interop' //模块的名称必须和system.js的配置文件(importmap.json)中的模块名称保持一致 setPublicPath('appVueHash')
4. 修改打包配置(vue.config.js
)
single-spa
模式和正常模式只有入口文件不同,其他的都一样。也就是说打包之后,只有app.js
文件不同,那么其他的文件是否可以复用,能否实现一次打包,即可部署两种模式?
答案是可以的:打包的时候我先执行sing-spa
的打包,然后执行正常模式打包,最后将single-spa
打包生成的app.js
文件拷贝到正常打包的文件根目录下。这样只需要拿着dist
目录部署即可,single-spa
不需要做任何修改即可同步更新。
需要注意的是文件不能带有hash值了,文件没了hash值就需要服务器自己生成hash值来设置缓存了。
const CopyPlugin = require('copy-webpack-plugin'); const env = process.env.VUE_APP__ENV; // 是否是single-spa const modeEnv = process.env.NODE_ENV; // 开发环境还是生产环境 const config = { productionSourceMap: false,//去掉sourceMap filenameHashing: false,//去掉文件名的hash值 }; const enteyFile = env === 'singleSpa' ? './src/index.spa.js' : './src/index.js'; //正常打包的app.js在js目录下,而single-spa模式则需要在根目录下。 //打包时会从dist-spa/js目录将app.js拷贝到正常打包的根目录下,所以不用管,只需要判断single-spa的开发模式即可 const filename = modeEnv === 'development' ? '[name].js' : 'js/[name].js'; chainWebpack = config => { config.entry('app') .add(enteyFile) .end() .output .filename(filename); if(env === 'singleSpa'){ //vue,vue-router不打包进app.js,使用外链 config.externals(['vue', 'vue-router']) } } if(env === 'singleSpa'){ Object.assign(config, { outputDir: 'dist-spa', devServer: { hot: false,//关闭热更新 port: 7778 }, chainWebpack, }) }else{ Object.assign(config, { chainWebpack, configureWebpack: modeEnv === 'production' ? { plugins: [ //将single-spa模式下打包生成的app.js拷贝到正常模式打包的主目录 new CopyPlugin([{ from: 'dist-spa/js/app.js', to: '' }]) ], } : {}, }) } module.exports = config;
打包后的文件效果:
其中js/app.js
是正常模式生成的,而与index.html
同目录的app.js
是dist-spa/js/app.js
拷贝过来的,是single-spa
模式的入口文件,其他的文件复用。
5. 修改打包命令(package.json
)
single-spa
模式下开发/打包都需要改动环境变量,将正常的build
命令修改成:按顺序打包两次,就可以实现和原来一样打包部署流程。
"scripts": { "spa-serve": "vue-cli-service serve --mode devSingleSpa", "serve": "vue-cli-service serve", "spa-build": "vue-cli-service build --mode singleSpa", "usual-build": "vue-cli-service build", "build": "npm run spa-build && npm run usual-build", "lint": "vue-cli-service lint" },
single-spa
开发使用npm run spa-serve
,正常开发不变。
打包任然使用npm run build
,然后将dist
目录下的文件部署到子项目服务器即可。
history模式路由的vue项目
由于我们给子项目路由强行加了不同前缀(/app-vue-history
),在hash
模式是没问题的,因为hash
模式下路由跳转只会修改url
的hash
值,不会修改path
值。history
模式则需要告诉vue-router
,/app-vue-history/
是项目路由前缀,跳转只需要修改这后面的部分,否则路由跳转会直接覆盖全部路径。那么这个配置项就是base
属性:
const router = new VueRouter({ mode: "history", base: '/',//默认是base routes, });
办法也很简单,判断下环境变量,single-spa
模式下base
属性是/app-vue-history
,正常模式则不变。
但是由于我们打包后复用了除app.js
以外的文件,所以只有入口文件才能区分开环境,解决办法是:
router/index.js
路由文件不导出实例化的路由对象,而导出一个函数:
const router = base => new VueRouter({ mode: "history", base, routes, });
并且main.js
不再引入路由文件,改成在入口文件分别引入。
正常模式的入口文件index.js
:
import router from './router'; const baseUrl = '/'; appOptions.router = router(baseUrl);
single-spa
模式的入口文件index.spa.js
:
import router from './router'; const baseUrl = '/app-vue-history'; appOptions.router = router(baseUrl);
部分原理浅析
sysyem.js的作用及好处
system.js
的作用就是动态按需加载模块。假如我们子项目都使用了vue
,vuex
,vue-router
,每个项目都打包一次,就会很浪费。system.js
可以配合webpack
的externals
属性,将这些模块配置成外链,然后实现按需加载:
当然了,你也可以直接用script
标签将这些公共的js
全部引入,但是这样会造成浪费,比如说子项目A用到了vue-router
和axios
,但是没用到vuex
,子项目A刷新,则还是会请求vuex
,就很浪费,system.js
则会按需加载。
同时,子项目打包成umd
格式,system.js
可以实现按需加载子项目。
systemjs-webpack-interop 插件有什么作用(GitHub地址)
上一篇文章中讲到,直接引入子项目的js/css
可以呈现出子系统,但是动态生成的HTML
中,img/video/audio
等文件的路径是相对的,导致加载不出来。而解决办法1是:修改vue-cli4
的 publicPath
设置为完整的绝对路径http://localhost:8080/
即可。
这个插件作用就是将子项目的publicPath
暴露出来给system.js
,system.js
根据项目名称匹配到配置文件(importmap.json
),然后解析配置的url
,将前缀赋给publicPath
。
那么publicPath
如何动态设置呢?webpack官网中给出的办法是:webpack
暴露了一个名为 __webpack_public_path__
的全局变量,直接修改这个值即可。
systemjs-webpack-interop
部分源码截图(public-path-system-resolve.js
):
所以这也是为什么single-spa
的入口文件app.js
要和index.html
目录一致,因为他直接截取了app.js
的路径作为了publicPath
。
single-spa-vue 插件有什么作用 (GitHub地址)
这个插件的主要作用是帮我们写了single-spa
所需要的三个周期事件:bootstrap
,mount
,unmount
。
在mount
周期做的事情就是生成我们需要的<div id="app"></div>
,当然了,id的名称它是根据项目名取得:
然后就是在这个div
里面实例化vue
:
所以如果我们想让子项目内容在我们自定义的区域(默认插入到body
),其中一个办法是将div
写好:
home-nav/public/index.html
:
另一个办法就是修改这部分代码,让他插入到我们想要插入的地方,而不是body
。
unmount
周期它卸载了实例化的vue
并且清空了DOM
,想要实现keep-alive
效果我们得修改这部分代码(后面有介绍)
vue-cli-plugin-single-spa 插件的作用(GitHub地址)
这个插件主要是用于命令vue add single-spa
执行时,覆盖你的main.js
并且生成set-public-path.js
,同时修改你的webpack
配置。但是执行npm install vue-cli-plugin-single-spa -D
命令时,它只会覆盖你的webpack
配置。
其修改webpack
配置的源码:
module.exports = (api, options) => { options.css.extract = false api.chainWebpack(webpackConfig => { webpackConfig .devServer .headers({ 'Access-Control-Allow-Origin': '*', }) .set('disableHostCheck', true) webpackConfig.optimization.delete('splitChunks') webpackConfig.output.libraryTarget('umd') webpackConfig.set('devtool', 'sourcemap') }) }
回到最初的起点,我们实现single-spa
最重要的事:动态引入子项目的js/css
,但是你发现没有,全程都只看到js
的引入,丝毫没有提及css
,那么css
文件咋办?答案就是options.css.extract = false
。
vue-cli3
官网中介绍,这个值为false
,就是不单独生成css
文件,和js
文件打包到一起,这让我们只需要关心js
文件的引入即可,但是也为css
污染问题埋下了坑。
另一个配置就是允许跨域,同时还有文章开头提及的system.js
要求子项目打包成umd
形式,也是它配置的。
还有一个比较关键的配置:webpackConfig.optimization.delete('splitChunks')
,正常情况下,我们打包之后的文件除了入口文件app.js
,还有一个文件是chunk-vendors.js
,这个文件里面包含了一些公共的第三方插件,这样一来,子项目就有两个入口文件(或者说得同时加载这两个文件),所以只能去掉splitChunks
。
注意事项及其他细节
- 环境变量
部署的时候除入口文件(app.js
)外,其他的路由文件都复用了正常打包的文件,所以环境变量需要由入口文件注入到全局使用。
index.spa.js
文件:
appOptions.store.commit('setSingleSpa',true);
- 子项目开发最好设置固定端口
避免频繁修改配置文件,设置一个固定的特殊端口,尽量避免端口冲突。
- single-spa 关闭热更新
开发模式仍正常开发,但是single-spa
联调需要关闭热更新,否则本地websocket
会一直报failed
。
single-spa
开发中我发现热更新正常生效。
- index.html里面的外部文件引入url需要写成绝对路径
配置文件注意写成绝对路径,否则访问子项目的时候路由重定向回主项目的index.html
,里面的url相对目录会出错。
home-nav/public/index.html
:
<script type="systemjs-importmap" src="/config/importmap.json"></script>
- 如何实现“keep-alive”
查看single-spa-vue
源码可以发现,在unmount
生命周期,它将vue
实例destroy
(销毁了)并且清空了DOM
。要想实现keep-alive
,我们只需要去掉destroy
并且不清空DOM
,然后自己使用display:none
来隐藏和显示子项目的DOM
即可。
function unmount(opts, mountedInstances) { return Promise .resolve() .then(() => { mountedInstances.instance.$destroy(); mountedInstances.instance.$el.innerHTML = ''; delete mountedInstances.instance; if (mountedInstances.domEl) { mountedInstances.domEl.innerHTML = '' delete mountedInstances.domEl } }) }
- 如何避免css污染
我们使用配置css.extract = true
之后,css
不再单独生成文件,而是打包到js
里面,生成的样式包裹在style
标签里面,子项目卸载之后,样式文件并没有删除,样式多了就可能造成样式污染。
解决办法:
办法1:命名规范 + css-scope
+ 去掉全局样式
办法2:卸载应用的时候去掉样式的style
标签(待研究)
如果一定要写全局变量,可以用类似“换肤”的办法解决:在子项目给body/html
加一个唯一的id(正常开发部署用),然后这个全局的样式前面加上这个id,而single-spa
模式则需要修改single-spa-vue
,在mount
周期给body/html
加上这个唯一的id,在unmount
周期去掉,这样就可以保证这个全局css只对这个项目生效了。
- 如何避免js冲突
首先得规范开发:在组件的destroy
生命周期去掉全局的属性/事件,其次还有个办法就是在子项目加载之前对window
对象做一个快照,然后在卸载的时候恢复之前的状态。
- 子项目如何通信
可以借助localstorage
和自定义事件通信。localstorage
一般用来共享用户的登陆信息等,而自定义事件一般用于共享实时数据,例如消息数量等。
//1、子组件A 创建事件并携带数据 const myCustom = new CustomEvent("custom",{ detail: { data: 'test' } }); //2、子组件B 注册事件监听器 window.addEventListener("custom",function(e){ //接收到数据 }) //3、子组件A触发事件 window.dispatchEvent(myCustom);
- 如何控制子系统的权限
其中一个办法就是没权限的系统直接隐藏入口导航,然后就是直接输入url
进入,还是会加载子项目,但是子项目判断无权限之后显示一个403页面即可。可以看到子系统对应的入口文件是写在一个json
文件里面的,那么总不能所有人都能读取到这个json
吧,或者说想实现不同权限的用户的json
配置不同。
我们可以动态生成script
标签:
//在加载模块之前先生成配置json function insertNewImportMap(newMapJSON) { const newScript = document.createElement('script') newScript.type = 'systemjs-importmap'; newScript.innerText = JSON.stringify(newMapJSON); const test = document.querySelector('#test') test.insertAdjacentElement('beforebegin',newScript); } //内容从接口获取 const devDependencies = { imports: { "navbar": "http://localhost:8083/app.js", "app1": "http://localhost:8082/app.js", "app2": "http://localhost/app.js", "single-spa": "https://cdnjs.cloudflare.com/ajax/libs/single-spa/4.3.7/system/single-spa.min.js", "vue": "https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js", "vue-router": "https://cdn.jsdelivr.net/npm/vue-router@3.0.7/dist/vue-router.min.js" } } insertNewImportMap(devDependencies);
总结
如果不想自己搭建node静态文件服务器,给大家推荐一个软件:XAMPP
文章中的完整demo
文件地址:github.com/gongshun/si…
- 目前存在的问题
- 子项目之间路由跳转没法去掉
url
的hash
值,例如从'/app1/#/home'
跳转到'/app2/'
时,hash值仍会被带上:'/app2/#/'
,目前看无影响,但是有可能会影响到子项目的路由判断。 - 子项目之间即使是同一技术栈也没法统一框架版本,虽然目前是有将公共框架抽离出来的操作,但是实际工作中可能比较难控制。
- 项目整体开发调试的时候,如果A项目是开发环境,而B项目是打包环境,路由来回切换则会报错,两个都是开发环境,或者两个都是生产环境则不会。(原因未知)
- 下一步计划
- 研究阿里的
qiankun
框架 react
项目改造和angular
项目改造,虽然原理类似,但是细节还是会不同
最后,感谢大家阅读,祝大家新年快乐!
有什么问题欢迎指出,下一篇文章预计年后更新了,需要大量实践总结。