上一篇文章:从0实现一个前端微服务(上)中讲到,single-spa
的原理就是,将子项目中的link/script
标签和<div id="app"></div>
插入到主项目,而这个操做的核心就是动态加载js
和css
。javascript
动态加载js
咱们使用的是system.js
,借助这个插件,咱们只须要将子项目的app.js
暴露给它便可。css
本文章基于GitHub上一个single-spa的demo修改,因此最好有研究过这个demo
,另外本文的基于最新的vue-cli4
开发。html
要实现的效果就是子项目独立开发部署,顺便还能被主项目集成。前端
vue-cli4
直接使用vue create nav
命令生成一个vue
项目。须要注意的是,导航项目路由必须用 history 模式 vue
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>
复制代码
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"
}
}
复制代码
若是是新开发的项目,能够先用vue-cli4
生成一个vue
项目,路由使用的是hash
模式。java
若是是老项目,须要分别安装一下三个插件:node
npm install systemjs-webpack-interop -S
复制代码
npm install single-spa-vue -S
复制代码
npm install vue-cli-plugin-single-spa -D
复制代码
若是是新项目,则可使用如下命令:react
vue add single-spa
复制代码
注意:该命令会改写你的 main.js,老项目不要用这个命令 webpack
该命令作了四事件:ios
(1) 安装 single-spa-vue
插件
(2) 安装 systemjs-webpack-interop
插件,并生成 set-public-path.js
(3) 修改main.js
(4) 修改webpack
配置(容许跨域,关闭热更新,去掉splitChunks
等)
因为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
复制代码
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')
复制代码
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
模式的入口文件,其余的文件复用。
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
目录下的文件部署到子项目服务器便可。
因为咱们给子项目路由强行加了不一样前缀(/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);
复制代码
system.js
的做用就是动态按需加载模块。假如咱们子项目都使用了vue
,vuex
,vue-router
,每一个项目都打包一次,就会很浪费。system.js
能够配合webpack
的externals
属性,将这些模块配置成外链,而后实现按需加载:
固然了,你也能够直接用script
标签将这些公共的js
所有引入,可是这样会形成浪费,好比说子项目A用到了vue-router
和axios
,可是没用到vuex
,子项目A刷新,则仍是会请求vuex
,就很浪费,system.js
则会按需加载。
同时,子项目打包成umd
格式,system.js
能够实现按需加载子项目。
上一篇文章中讲到,直接引入子项目的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
所须要的三个周期事件: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 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
联调须要关闭热更新,不然本地websocket
会一直报failed
。
single-spa
开发中我发现热更新正常生效。
配置文件注意写成绝对路径,不然访问子项目的时候路由重定向回主项目的index.html
,里面的url相对目录会出错。
home-nav/public/index.html
:
<script type="systemjs-importmap" src="/config/importmap.json"></script>
复制代码
查看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.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只对这个项目生效了。
首先得规范开发:在组件的destroy
生命周期去掉全局的属性/事件,其次还有个办法就是在子项目加载以前对window
对象作一个快照,而后在卸载的时候恢复以前的状态。
能够借助localstorage
和自定义事件通讯。localstorage
通常用来共享用户的登录信息等,而自定义事件通常用于共享实时数据,例如消息数量等。
//一、子组件A 建立事件并携带数据
const myCustom = new CustomEvent("custom",{ detail: { data: 'test' } });
//二、子组件B 注册事件监听器
window.addEventListener("custom",function(e){
//接收到数据
})
//三、子组件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
项目改造,虽然原理相似,可是细节仍是会不一样最后,感谢你们阅读,祝你们新年快乐!
有什么问题欢迎指出,下一篇文章预计年后更新了,须要大量实践总结。