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

上一篇文章: 从0实现一个single-spa的前端微服务(中)
中我们已经实现了 single-spa
+ systemJS
的前端微服务以及完善的开发和打包配置,今天主要讲一下这个方案存在的细节问题,以及 qiankun
框架的一些研究对比。

single-spa + systemJs 方案存在的问题及解决办法

single-spa
的三个生命周期函数 bootstrap
mount
unmount
分别表示初始化、加载时、卸载时。

  1. 子系统导出 bootstrap
    mount
    unmount
    函数是必需的,但是 unload
    是可选的。
  2. 每个生命周期函数必须返回 Promise
  3. 如果导出一个函数数组(而不只是一个函数),这些函数将一个接一个地调用,等待一个函数的 promise
    解析后再调用下一个。

css污染问题的解决

我们知道,子系统卸载之后,其引入的 css
并不会被删掉,所以在子系统卸载时删掉这些 css
,是一种解决 css
污染的办法,但是不太好记录子系统引入了哪些 css

我们可以借助换肤的思路来解决 css
污染,首先 css-scoped
解决95%的样式污染,然后就是全局样式可能会造成污染,我们只需要将全局样式用一个 id/class
包裹着就可以了,这样这些全局样式仅在这个 id/class
范围内生效。

具体做法就是:在子系统加载时( mount
)给 <body>
加一个特殊的 id/class
,然后在子系统卸载时( unmount
)删掉这个 id/class
。而子系统的全局样式都仅在这个 id/class
范围内生效,如果子系统独立运行,只需要在子系统的入口文件 index.html
里面给 <body>
手动加上这个 id/class
即可。

代码如下:

async function mount(props){
  //给body加class,以解决全局样式污染
  document.body.classList.add('app-vue-history')
}
async function unmount(props){
  //去掉body的class
  document.body.classList.remove('app-vue-history')
}
复制代码

js污染问题的解决

暂时没有很好的办法解决,但是可以靠编码规范来约束:页面销毁之前清除自己页面上的定时器/全局事件,必要的时候,全局变量也应该销毁。

如何实现切换系统更换favicon.ico图标

这是一个比较常见的需求,类似还有某个系统需要插入一段特殊的 js/css
,而其他系统不需要,解决办法任然是在子系统加载时( mount
)插入需要的 js/css
,在子系统卸载时( unmount
)删掉。

const headEle = document.querySelector('head');
let linkEle = null ;
// 因为新插入的icon会覆盖旧的,所以旧的不用删除,如果需要删除,可以在unmount时再插入进来
async function mount(props){
  linkEle = document.createElement("link");
  linkEle.setAttribute('rel','icon');
  linkEle.setAttribute('href','https://gold-cdn.xitu.io/favicons/favicon.ico');
  headEle.appendChild(linkEle);
}
async function unmount(props){
  headEle.removeChild(linkEle);
  linkEle = null;
}
复制代码

系统之间如何通信

系统之间通信一般有两种方式:自定义事件和本地存储。如果是两个系统相互跳转,可以用 URL
数据

一般来说,不会同时存在A、B两个子系统,常见的数据共享就是登陆信息,登陆信息一般使用本地存储记录。另外一个常见的场景就是子系统修改了用户信息,主系统需要重新请求用户信息,这个时候一般用自定义事件通信,自定义事件具体如何操作,可以看上一篇文章的例子。

另外, single-spa
的注册函数 registerApplication
,第四个参数可以传递数据给子系统,但传递的数据必须是一个 对象

注册子系统的时候:

singleSpa.registerApplication(
    'appVueHistory',
    () => System.import('appVueHistory'),
    location => location.pathname.startsWith('/app-vue-history/'),
    { authToken: "d83jD63UdZ6RS6f70D0" }
)
复制代码

子系统( appVueHistory
)接收数据:

export function mount(props) {
  //官方文档写的是props.customProps.authToken,实际上发现是props.authToken
  console.log(props.authToken); 
  return vueLifecycles.mount(props);
}
复制代码

关于子系统的生命周期函数:

  1. 生命周期函数 bootstrap
    , mount
    unmount
    均包含参数 props
  2. 参数 props
    是一个对象,包含 name
    singleSpa
    mountParcel
    customProps
    。不同的版本可能略有差异
  3. 参数对象中 customProps
    就是注册的时候传递过来的参数

子系统如何实现keep-alive

查看 single-spa-vue
源码可以发现,在 unmount
生命周期,它将 vue
实例 destroy
(销毁了)并且清空了 DOM
。所以实现 keep-alive
的关键在于子系统的 unmount
周期中不销毁 vue
实例并且不清空 DOM
,采用 display:none
来隐藏子系统。而在 mount
周期,先判断子系统是否存在,如果存在,则去掉其 display:none
即可。

我们需要修改 single-spa-vue
的部分源代码:

function mount(opts, mountedInstances, props) {
  let instance = mountedInstances[props.name];
  return Promise.resolve().then(() => {
    //先判断是否已加载,如果是,则直接将其显示出来
    if(!instance){
      //这里面都是其源码,生成DOM并实例化vue的部分
      instance = {};
      const appOptions = { ...opts.appOptions };
      if (props.domElement && !appOptions.el) {
        appOptions.el = props.domElement;
      }
      let domEl;
      if (appOptions.el) {
        if (typeof appOptions.el === "string") {
          domEl = document.querySelector(appOptions.el);
          if (!domEl) {
            throw Error(
              `If appOptions.el is provided to single-spa-vue, the dom element must exist in the dom. Was provided as ${appOptions.el}`
            );
          }
        } else {
          domEl = appOptions.el;
        }
      } else {
        const htmlId = `single-spa-application:${props.name}`;
        // CSS.escape的文档(需考虑兼容性):https://developer.mozilla.org/zh-CN/docs/Web/API/CSS/escape
        appOptions.el = `#${CSS.escape(htmlId)}`;
        domEl = document.getElementById(htmlId);
        if (!domEl) {
          domEl = document.createElement("div");
          domEl.id = htmlId;
          document.body.appendChild(domEl);
        }
      }
      appOptions.el = appOptions.el + " .single-spa-container";
      // single-spa-vue@>=2 always REPLACES the `el` instead of appending to it.
      // We want domEl to stick around and not be replaced. So we tell Vue to mount
      // into a container div inside of the main domEl
      if (!domEl.querySelector(".single-spa-container")) {
        const singleSpaContainer = document.createElement("div");
        singleSpaContainer.className = "single-spa-container";
        domEl.appendChild(singleSpaContainer);
      }
      instance.domEl = domEl;
      if (!appOptions.render && !appOptions.template && opts.rootComponent) {
        appOptions.render = h => h(opts.rootComponent);
      }
      if (!appOptions.data) {
        appOptions.data = {};
      }
      appOptions.data = { ...appOptions.data, ...props };
      instance.vueInstance = new opts.Vue(appOptions);
      if (instance.vueInstance.bind) {
        instance.vueInstance = instance.vueInstance.bind(instance.vueInstance);
      }
      mountedInstances[props.name] = instance;
    }else{
      instance.vueInstance.$el.style.display = "block";
    }
    return instance.vueInstance;
  });
}
function unmount(opts, mountedInstances, props) {
  return Promise.resolve().then(() => {
    const instance = mountedInstances[props.name];
    instance.vueInstance.$el.style.display = "none";
  });
}
复制代码

而子系统内部页面则和正常 vue
系统一样使用 <keep-alive>
标签来实现缓存

如何实现子系统的预请求(预加载)

vue-router
路由配置的时候可以使用按需加载(代码如下),按需加载之后路由文件就会单独打包成一个 js
css

path: "/about",
name: "about",
component: () => import( "../views/About.vue")
复制代码

vue-cli3
生成的模板打包后的 index.html
中是有使用 prefetch
preload
来实现路由文件的预请求的:

<link href=/js/about.js rel=prefetch>
<link href=/js/app.js rel=preload as=script>
复制代码

prefetch
预请求就是:浏览器网络空闲的时候请求并缓存文件

systemJs
只能拿到入口文件,其他的路由文件是按需加载的,无法实现预请求。但是如果你没有使用路由的按需加载,则所有路由文件都打包到一个文件( app.js
),则可以实现预请求。

上述完整 demo
文件地址: github.com/gongshun/si…

qiankun框架

qiankun
是蚂蚁金服开源的基于 single-spa
的一个前端微服务框架。

js沙箱(sandbox)是如何实现的

我们知道所有的全局的方法( alert
setTimeout
isNaN
等)、全局的变/常量( NaN
Infinity
var
声明的全局变量等)和全局对象( Array
String
Date
等)都属于 window
对象,而能导致 js
污染的也就是这些全局的方法和对象。

所以 qiankun
解决 js
污染的办法是:在子系统加载之前对 window
对象做一个快照(拷贝),然后在子系统卸载的时候恢复这个快照,即可以保证每次子系统运行的时候都是一个全新的 window
对象环境。

那么如何监测 window
对象的变化呢,直接将 window
对象进行一下深拷贝,然后深度对比各个属性显然可行性不高, qiankun
框架采用的是 ES6
新特性, proxy
代理方法。

具体代码如下(源代码是 ts
版的,我简化修改了一些):

// 沙箱期间新增的全局变量
const addedPropsMapInSandbox = new Map();
// 沙箱期间更新的全局变量
const modifiedPropsOriginalValueMapInSandbox = new Map();
// 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot
const currentUpdatedPropsValueMap = new Map();
const boundValueSymbol = Symbol('bound value');
const rawWindow = window;
const fakeWindow = Object.create(null);
const sandbox = new Proxy(fakeWindow, {
    set(target, propKey, value) {
      if (!rawWindow.hasOwnProperty(propKey)) {
        addedPropsMapInSandbox.set(propKey, value);
      } else if (!modifiedPropsOriginalValueMapInSandbox.has(propKey)) {
        // 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值
        const originalValue = rawWindow[propKey];
        modifiedPropsOriginalValueMapInSandbox.set(propKey, originalValue);
      }
      currentUpdatedPropsValueMap.set(propKey, value);
      // 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据
      rawWindow[propKey] = value;
      // 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
      return true;
    },
    get(target, propKey) {
      if (propKey === 'top' || propKey === 'window' || propKey === 'self') {
        return sandbox;
      }
      const value = rawWindow[propKey];
      // isConstructablev :监测函数是否是构造函数
      if (typeof value === 'function' && !isConstructable(value)) {
        if (value[boundValueSymbol]) {
          return value[boundValueSymbol];
        }
        const boundValue = value.bind(rawWindow);
        Object.keys(value).forEach(key => (boundValue[key] = value[key]));
        Object.defineProperty(value, boundValueSymbol, { enumerable: false, value: boundValue });
        return boundValue;
      }
      return value;
    },
    has(target, propKey) {
      return propKey in rawWindow;
    },
});
复制代码

大致原理就是记录 window
对象在子系统运行期间新增、修改和删除的属性和方法,然后会在子系统卸载的时候复原这些操作。

这样处理之后,全局变量可以直接复原,但是事件监听和定时器需要特殊处理:用 addEventListener
添加的事件,需要用 removeEventListener
方法来移除,定时器也需要特殊函数才能清除。所以它重写了事件绑定/解绑和定时器相关函数。

重写定时器( setInterval
)部分代码如下:

const rawWindowInterval = window.setInterval;
const hijack = function () {
  const timerIds = [];
  window.setInterval = (...args) => {
    const intervalId = rawWindowInterval(...args);
    intervalIds.push(intervalId);
    return intervalId;
  };
  return function free() {
    window.setInterval = rawWindowInterval;
    intervalIds.forEach(id => {
      window.clearInterval(id);
    });
  };
}

复制代码

由于qiankun在js沙箱功能中使用了proxy新特性,所以它的兼容性和vue3一样,不支持IE11及以下版本的IE。不过作者说可以尝试禁用沙箱功能来提高兼容性,但是不保证都能运行。去掉了js沙箱功能,就变得索然无味了。

css污染他是如何解决的

它解决 css
污染的办法是:在子系统卸载的时候,将子系统引入 css
使用的 <link>
<style>
标签移除掉。移除的办法是重写 <head>
标签的 appendChild
方法,办法类似定时器的重写。

子系统加载时,会将所需要的 js/css
文件插入到 <head>
标签,而重写的 appendChild
方法会记录所插入的标签,然后子系统卸载的时候,会移除这些标签。

预请求是如何实现的

解决子系统预请求的的根本在于,我们需要知道子系统有哪些 js/css
需要加载,而借助 systemJs
加载子系统,只知道子系统的入口文件( app.js
)。 qiankun
不仅支持 app.js
作为入口文件,还支持 index.html
作为入口文件,它会用正则匹配出 index.html
里面的 js/css
标签,然后实现预请求。

网络不好和移动端访问的时候, qiankun
不会进行预请求,移动端大多是使用数据流量,预请求则会浪费用户流量,判断代码如下:

const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
const isSlowNetwork = navigator.connection
  ? navigator.connection.saveData || /(2|3)g/.test(navigator.connection.effectiveType)
  : false;
复制代码

请求 js/css
文件它采用的是 fetch
请求,如果浏览器不支持,还需要 polyfill

以下代码就是它请求 js
并进行缓存:

const defaultFetch = window.fetch.bind(window);
//scripts是用正则匹配到的script标签
function getExternalScripts(scripts, fetch = defaultFetch) {
    return Promise.all(scripts.map(script => {
	if (script.startsWith('<')) {
	    // 内联js代码块
	    return getInlineCode(script);
	} else {
	    // 外链js
	    return scriptCache[script] ||
	           (scriptCache[script] = fetch(script).then(response => response.text()));
	}
    }));
}
复制代码

用qianklun框架实现微前端

qiankun
的 源码
中已经给出了使用示例,使用起来也非常简单好用。接下来我演示下如何从0开始用 qianklun
框架实现微前端,内容改编自官方使用示例。

主项目main

  1. vue-cli3
    生成一个全新的 vue
    项目,注意路由使用 history
    模式。
  2. 安装 qiankun
    框架: npm i qiankun -S
  3. 修改 app.vue
    ,使其成为菜单和子项目的容器。其中两个数据, loading
    就是加载的状态,而 content
    则是子系统生成的 HTML
    片段(子系统独立运行时,这个 HTML
    片段会被插入到 #app
    里面的)
<template>
  <div id="app">
    <header>
      <router-link to="/app-vue-hash/">app-vue-hash</router-link>
      <router-link to="/app-vue-history/">app-vue-history</router-link>
    </header>
    <div v-if="loading" class="loading">loading</div>
    <div class="appContainer" v-html="content">content</div>
  </div>
</template>

<script>
export default {
  props: {
    loading: {
      type: Boolean,
      default: false
    },
    content: {
      type: String,
      default: ''
    },
  },
}
</script>
复制代码
  1. 修改 main.js
    ,注册子项目,子项目入口文件采用 index.html
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import { registerMicroApps, start } from 'qiankun';
Vue.config.productionTip = false
let app = null;
function render({ appContent, loading }) {
  if (!app) {
    app = new Vue({
      el: '#container',
      router,
      data() {
        return {
          content: appContent,
          loading,
        };
      },
      render(h){
        return h(App, {
          props: {
            content: this.content,
            loading: this.loading,
          },
        })
      } 
    });
  } else {
    app.content = appContent;
    app.loading = loading;
  }
}
function initApp() {
  render({ appContent: '', loading: false });
}
initApp();
function genActiveRule(routerPrefix) {
  return location => location.pathname.startsWith(routerPrefix);
}
registerMicroApps([
  { name: 'app-vue-hash', entry: 'http://localhost:80', render, activeRule: genActiveRule('/app-vue-hash') },
  { name: 'app-vue-history', entry: 'http://localhost:1314', render, activeRule: genActiveRule('/app-vue-history') },
]);
start();
复制代码

注意:主项目中的 index.html
模板里面的 <div id="app"></div>
需要改为 <div id="container"></div>

子项目app-vue-hash

  1. vue-cli3
    生成一个全新的 vue
    项目,注意路由使用 hash
    模式。
  2. src
    目录新增文件 public-path.js
    ,注意用于修改子项目的 publicPath
if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
复制代码
  1. 修改 main.js
    ,配合主项目导出 single-spa
    需要的三个生命周期。注意:路由实例化需要在main.js里面完成,以便于路由的销毁,所以路由文件只需要导出路由配置即可(原模板导出的是路由实例)
import './public-path';
import Vue from 'vue';
import VueRouter from 'vue-router';
import App from './App.vue';
import routes from './router';
import store from './store';

Vue.config.productionTip = false;
let router = null;
let instance = null;
function render() {
  router = new VueRouter({
    routes,
  });
  instance = new Vue({
    router,
    store,
    render: h => h(App),
  }).$mount('#appVueHash');// index.html 里面的 id 需要改成 appVueHash,否则子项目无法独立运行
}
if (!window.__POWERED_BY_QIANKUN__) {//全局变量来判断环境
  render();
}
export async function bootstrap() {
  console.log('vue app bootstraped');
}
export async function mount(props) {
  console.log('props from main framework', props);
  render();
}
export async function unmount() {
  instance.$destroy();
  instance = null;
  router = null;
}
复制代码
  1. 修改打包配置文件 vue.config.js
    ,主要是允许跨域、关闭热更新、去掉文件的 hash
    值、以及打包成 umd
    格式
const path = require('path');
const { name } = require('./package');
function resolve(dir) {
  return path.join(__dirname, dir);
}
const port = 7101; // dev port
module.exports = {
  filenameHashing: true,
  devServer: {
    hot: true,
    disableHostCheck: true,
    port,
    overlay: {
      warnings: false,
      errors: true,
    },
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
  // 自定义webpack配置
  configureWebpack: {
    output: {
      // 把子应用打包成 umd 库格式
      library: `${name}-[name]`,
      libraryTarget: 'umd',
      jsonpFunction: `webpackJsonp_${name}`,
    },
  },
};

复制代码

子项目app-vue-history

history
模式的 vue
项目与 hash
模式只有一个地方不同,其他的一模一样。

main.js
里面路由实例化的时候需要加入条件判断,注入路由前缀

function render() {
  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? '/app-vue-history' : '/',
    mode: 'history',
    routes,
  });

  instance = new Vue({
    router,
    store,
    render: h => h(App),
  }).$mount('#appVueHistory');
}
复制代码

其他

  1. 如果想关闭 js
    沙箱和预请求,在 start
    函数中配置即可
start({
    prefetch: false, //默认是true,可选'all'
    jsSandbox: false, //默认是true
})
复制代码
  1. 子项目注册函数 registerMicroApps
    也可以传递数据给子项目,并且可以设置全局的生命周期函数
// 其中app对象的props属性就是传递给子项目的数据,默认是空对象
registerMicroApps(
  [
    { name: 'app-vue-hash', entry: 'http://localhost:80', render, activeRule: genActiveRule('/app-vue-hash') , props: { data : 'message' } },
    { name: 'app-vue-history', entry: 'http://localhost:1314', render, activeRule: genActiveRule('/app-vue-history') },
  ],
  {
    beforeLoad: [
      app => { console.log('before load', app); },
    ],
    beforeMount: [
      app => { console.log('before mount', app); },
    ],
    afterUnmount: [
      app => { console.log('after unload', app); },
    ],
  },
);
复制代码
  1. qiankun
    的官方文档: qiankun.umijs.org/zh/api/#reg…

  2. 上述 demo
    的完整代码 github.com/gongshun/qi…

原文 

https://juejin.im/post/5e5ca537e51d4526f16e5065

本站部分文章源于互联网,本着传播知识、有益学习和研究的目的进行的转载,为网友免费提供。如有著作权人或出版方提出异议,本站将立即删除。如果您对文章转载有任何疑问请告之我们,以便我们及时纠正。

PS:推荐一个微信公众号: askHarries 或者qq群:474807195,里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多

转载请注明原文出处:Harries Blog™ » 从0实现一个single-spa的前端微服务(下)

赞 (0)
分享到:更多 ()

评论 0

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址