从0实现一个single-spa的前端微服务(中)

预备知识

上一篇文章:从0实现一个前端微服务(上)中讲到,single-spa的原理就是,将子项目中的link/script标签和<div id="app"></div>插入到主项目,而这个操做的核心就是动态加载jscssjavascript

动态加载js咱们使用的是system.js,借助这个插件,咱们只须要将子项目的app.js暴露给它便可。css

本文章基于GitHub上一个single-spa的demo修改,因此最好有研究过这个demo,另外本文的基于最新的vue-cli4开发。html

single-spa-vue实现步骤

要实现的效果就是子项目独立开发部署,顺便还能被主项目集成。前端

新建导航主项目

  1. vue-cli4直接使用vue create nav命令生成一个vue项目。

须要注意的是,导航项目路由必须用 history 模式 vue

  1. 修改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>
复制代码
  1. 子项目和公共文件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模式。java

1. 安装插件(稍后会介绍其做用):

若是是老项目,须要分别安装一下三个插件: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等)

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.jssingle-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.jsdist-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模式下路由跳转只会修改urlhash值,不会修改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能够配合webpackexternals属性,将这些模块配置成外链,而后实现按需加载:

固然了,你也能够直接用script标签将这些公共的js所有引入,可是这样会形成浪费,好比说子项目A用到了vue-routeraxios,可是没用到vuex,子项目A刷新,则仍是会请求vuex,就很浪费,system.js则会按需加载。

同时,子项目打包成umd格式,system.js能够实现按需加载子项目。

systemjs-webpack-interop 插件有什么做用(GitHub地址

上一篇文章中讲到,直接引入子项目的js/css能够呈现出子系统,可是动态生成的HTML中,img/video/audio等文件的路径是相对的,致使加载不出来。而解决办法1是:修改vue-cli4publicPath 设置为完整的绝对路径http://localhost:8080/便可。

这个插件做用就是将子项目的publicPath暴露出来给system.jssystem.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所须要的三个周期事件:bootstrapmountunmount

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

注意事项及其余细节

  1. 环境变量

部署的时候除入口文件(app.js)外,其余的路由文件都复用了正常打包的文件,因此环境变量须要由入口文件注入到全局使用。

index.spa.js文件:

appOptions.store.commit('setSingleSpa',true);
复制代码
  1. 子项目开发最好设置固定端口

避免频繁修改配置文件,设置一个固定的特殊端口,尽可能避免端口冲突。

  1. single-spa 关闭热更新

开发模式仍正常开发,可是single-spa联调须要关闭热更新,不然本地websocket会一直报failed

single-spa开发中我发现热更新正常生效。

  1. index.html里面的外部文件引入url须要写成绝对路径

配置文件注意写成绝对路径,不然访问子项目的时候路由重定向回主项目的index.html,里面的url相对目录会出错。

home-nav/public/index.html:

<script type="systemjs-importmap" src="/config/importmap.json"></script>
复制代码
  1. 如何实现“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
      }
    })
}
复制代码
  1. 如何避免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只对这个项目生效了。

  1. 如何避免js冲突

首先得规范开发:在组件的destroy生命周期去掉全局的属性/事件,其次还有个办法就是在子项目加载以前对window对象作一个快照,而后在卸载的时候恢复以前的状态。

  1. 子项目如何通讯

能够借助localstorage和自定义事件通讯。localstorage通常用来共享用户的登录信息等,而自定义事件通常用于共享实时数据,例如消息数量等。

//一、子组件A 建立事件并携带数据
const myCustom = new CustomEvent("custom",{ detail: { data: 'test' } });
//二、子组件B 注册事件监听器
window.addEventListener("custom",function(e){
  //接收到数据
})
//三、子组件A触发事件
window.dispatchEvent(myCustom);
复制代码
  1. 如何控制子系统的权限

其中一个办法就是没权限的系统直接隐藏入口导航,而后就是直接输入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…

  1. 目前存在的问题

    • 子项目之间路由跳转无法去掉urlhash值,例如从'/app1/#/home'跳转到'/app2/'时,hash值仍会被带上:'/app2/#/',目前看无影响,可是有可能会影响到子项目的路由判断。

    • 子项目之间即便是同一技术栈也无法统一框架版本,虽然目前是有将公共框架抽离出来的操做,可是实际工做中可能比较难控制。

    • 项目总体开发调试的时候,若是A项目是开发环境,而B项目是打包环境,路由来回切换则会报错,两个都是开发环境,或者两个都是生产环境则不会。(缘由未知)

  2. 下一步计划

    • 研究阿里的qiankun框架
    • react项目改造和angular项目改造,虽然原理相似,可是细节仍是会不一样

最后,感谢你们阅读,祝你们新年快乐!

有什么问题欢迎指出,下一篇文章预计年后更新了,须要大量实践总结。

相关文章
相关标签/搜索