1. 微前端

284 阅读5分钟

什么是微前端

  • 常见的应用模型:项目中会分为很多模块,会把这些模块组到一个项目中去
    • 导致的问题是:应用大 打包构建会慢 项目也会变得不好维护
  • 可以把每个模块都拆分成一个个的应用,把这些应用再放到一个主应用中运行,这就是微前端的思想。相当于把很多模块拆分成了不同的应用,可以交给不同的团队来管理,最后可以组装在一起
  • 微前端就是按照不同的功能拆分成多个子应用,再把子应用插到当前的主应用中

使用微前端的原因

  • 子应用可以使用不同的技术栈,还可以独立部署,解决了前端协同开发问题

微前端落地方案

  • single-spa 用于前端微服务化的javascript前端解决方案
    • 缺点: 没有处理父子应用或者平级应用的样式隔离问题,js执行隔离问题
    • single-spa仅仅做了一件事情:实现了路由劫持和应用加载
    • 核心就是可以根据不同的路由加载不同的应用
  • qiankun
    • 基于single-spa 提供了开箱即用的api
    • 解决了沙箱、文件导入等问题
  • 总结
    • 子应用可以独立构建,运行时动态加载主子应用完全解耦,技术栈无关
    • 靠的是协议接入(子应用必须导出bootstrap/mount/unmount方法)
  • iframe
    • 子应用切换路由时用户刷新页面就尴尬了
  • 应用通信
    • 基于url 传递消息能力弱
    • 基于CustomEvent实现通信
    • 基于props主子应用间通信
    • 使用全局变量、redux进行通信
  • 公共依赖
    • cdn externals
    • webpack 联邦模块

实战

single-spa

  • singleSpa 缺陷 不够灵活 不能动态加载js文件
  • 样式不隔离
  • 没有js沙箱的机制(加载不同的应用,每次切换的时候用的都是同一个window)
  • 用法
    • 在父应用中注册一个应用,当条件满足的时候会加载子应用的脚本
    • 加载这些脚本,子应用打包出来是一个类库, 会去调bootstrap/mount/unmount
    • 调mount时就会走appOptions里的方法,就会把子应用的脚本创建的dom元素放到父应用制定的dom中
    • 同时要保证子应用引用的路径都是绝对路径(是相对于自己的路径,不是相对于父路径)
/*
创建一个子应用 vue create child-vue
需要父应用加载子应用,子应用需要暴露出三个接口: bootstrap/mount/unmount
当使用single-spa 子应用要导出相应的方法,要用到生态里的包:single-spa-vue / single-spa-react
(npm install single-spa-vue)可以通过它生成三个暴露出去的函数

应用要打包成一个个lib去给父应用使用
*/ 
// child-vue项目  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;

// new Vue({
//     router,
//     render: h => h(App)
// }).$mount('#app');
// 不能直接再new Vue里 ,要把应用配置成一个对象
const appOptions = {
    // el表示要把它挂载到父应用的哪个标签上
    el: '#vue', // 挂载到父应用中的id为vue的标签中
    router,
    render: h => h(App),
}
// vueLifeCycle指的是包装后的三个方法 而不是vue中的生命周期
const vueLifeCycle = singleSpaVue({
    Vue,
    appOptions
}); // 传完以后会返回一个对象,对象中就包含着要暴露的三个方法

// 如果是父应用引用这个子应用,会有这样一个属性
if(window.singleSpaNavigate) {
  // 这样 发请求的时候 都会把这个路径拼在前面 就变成了一个绝对路径
  __webpack_public_path__ = 'http://localhost:10000/'
}

// 子应用独立运行
if(!window.singleSpaNavigate) {
  // 因为独立运行的时候没有#vue这个dom 手动删除el之后再渲染
  // 手动挂载到子应用的app这个dom上
  delete appOptions.el;
  new Vue(appOptions).$mount('#app');
}


// 协议接入 定好了协议 父应用会调用这些方法
export const bootstrap = vueLifeCycle.bootstrap;
export const mount = vueLifeCycle.mount;
export const unmount = vueLifeCycle.unmount;



// 怎么将子应用打包成一个lib: 在子应用根目录新建vue.config.js(webpack的基本配置) 打包方式要自己去配置
module.exports = {
    configureWebpack: {
        output: { // 打包成一个库
            library: 'singleVue',
            libraryTarget: 'umd', // 最终会把bootstrap/mount/unmount都放到window.singleVue上
        },
        devServer: {
            port: 10000,

        }
    }
}

// router/index.js
const router = new VueRouter({
  mode: 'history',
  base: '/vue', // 子应用启动后会自动以/vue为基准加载文件
  routes
})



/*
父应用可以是一个普通的html 可以是一个vue/react项目
创建一个父应用 vue create parent-vue
npm i single-spa
*/

// parent-vue项目 App.vue 
<template>
    <div id="app">
        {/* 当路径切换到/vue时 */}
        <router-link to="/vue">加载vue应用</router-link>
        {/* 子应用child-vue加载的位置 */}
        <div id="vue"></div>
    </div>
</template>

// 需要把子应用注册进来, 当匹配到/vue的时候就加载这个子应用(注册子应用并且挂载)
// parent-vue项目 main.js => npm i single-spa
import Vue from 'vue';
import App from './App.vue';
import router from './router';
import {registerApplication, start} from 'single-spa';

Vue.config.productionTip = false;

async function loadScript() {
  return new Promise((resolve ,reject) => {
    let script = document.createElement('script');
    script.src = url;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
  })
}

// 注册应用
// appName 参数一是给应用起个名字 名字随便给就行
// applicationOrLoadingFn 参数二是加载的方法 要求必须是个promise
// activetyFn 参数三激活的方法
registerApplication('myVueApp', 
    async () => {
      console.log("加载子应用");
      // 加载脚本 (single-spa推荐使用systemjs,相当于可以在浏览器引用es6模块)
      // 这里为了方便,采用动态创建script的方式
      // 加载顺序有要求 先加载公共的再加载app
      await loadScript('http://localhost:10000/js/chunk-vender.js');
      await loadScript('http://localhost:10000/js/app.js');
      return window.singleVue; // bootstrap mount unmount
    },
    // 用户切换到/vue的路径下,需要加载定义的子应用
    // 匹配到/vue之后就会去执行参数二的加载方法
    location => location.pathname.startsWith('/vue'), 
)

new Vue({
    router,
    render: h => h(App)
}).$mount('#app');

css隔离方案

  • 子应用之间的样式隔离
    • 动态样式表,当应用切换时移除老应用样式,添加新应用样式
  • 主应用和子应用之间的样式隔离
    • bem 约定项目前缀
    • css-modules 打包时生成不冲突的选择器名 编译时就完成
    • shadow dom 真正意义上的隔离
    • css-in-js 不建议
// 如果子应用没有往body上挂些属性的话 shadow dom方案是最靠谱的
// 但是如react项目中 很多弹框都是直接挂在body上的,样式就可能受影响
<body>
    <div>
        <p>hello world</p>
        <div id='shadow'></div>
    </div>
    <script>
        // Element.attachShadow()
        let shadowDOM = shadow.attachShadow({mode: 'closed'}); // 外界无法访问 shadow dom
        let pElm = document.createElement('p');
        p.innerHTML = 'hello zhuzhu';
        let styleElm = document.createElement('style');
        styleElm.textContent = `
            p{color: red}
        `
        shadowDOM.appendChild(styleElm);
        shadowDOM.appendChild(pElm);
        // 样式就只对shadow里的p标签起作用
        
        // appendChild具有移动的效果, 如果将p标签移到body上,会移动到shadow边界外
        document.body.appendChild(pElm);
    </script>
</body>

js沙箱

  • 创造一个干净的环境给这个子应用使用,当切换时可以选择丢弃属性和恢复属性
  • 沙箱的两种机制
    • 快照沙箱
      • 快照沙箱只能针对单实例应用场景,如果是多个实例同时挂载的情况则无法解决,只能通过proxy代理沙箱来实现
    • proxy沙箱
      • 把不同的应用用不同的代理去处理
      • 每个应用都创建一个proxy来代理window,好处是每个应用都是相对独立,不需要直接更改全局window属性
// 快照沙箱
class SnapshotSandbox {
  constructor() {
    this.proxy = window;
    this.modifyPropsMap = {}; //修改了哪些属性
    this.active();
  }
  active() {
    this.windowSnapshot = {}; // window对象的快照
    for(const prop in window) {
      if(window.hasOwnProperty(prop)) {
        // 将window上的属性进行拍照
        this.windowSnapshot[prop] = window[prop];
      }
    }
    Object.keys(this.modifyPropsMap).forEach(p => {
      window[p] = this.modifyPropsMap[p];
    });
  }
  inactive() {
    for(const prop in window) { // diff差异
      if(window.hasOwnProperty(prop)) {
        // 将上次拍照的结果和本次window属性做对比
        if(window[prop] !== this.windowSnapshot[prop]){
          // 保存修改后的结果
          this.modifyPropsMap[prop] = window[prop];
          // 还原window
          window[prop] = this.windowSnapshot[prop];
        }
      }
    }
  }
}

let sandbox = new SnapshotSandbox();
//通过沙箱返回一个代理
((window) => {
  window.a = 1;
  window.b = 2;
  window.c = 3;
  console.log(a,b,c);
  sandbox.inactive();
  console.log(a,b,c);
})(sandbox.proxy)
// 代理沙箱
class ProxySandbox {
  constructor() {
    const rawWindow = window;
    const fakeWindow = {};
    const proxy = new Proxy(fakeWindow, {
      set(target, p , value) {
        target[p] = value;
        return true;
      },
      get(target, p) {
        return target[p] || rawWindow[p];
      }
    });
    this.proxy = proxy;
  }
}

let sandbox1 = new ProxySandbox();
let sandbox2 = new ProxySandbox();
window.a = 1;
((window) => {
  window.a = 'hello';
  console.log(window.a);
})(sandbox1.proxy);
((window) => {
  window.a = 'world';
  console.log(window.a);
})(sandbox2.proxy);

qiankun

  • 是基于single-spa做的封装
  • 解决的文件引用的问题 / 样式隔离 / js沙箱 / 预加载
/*

父应用 vue create qiankun-base
*/

// App.vue
// {/* 基座中可以放自己的路由 */}
// <router-link to="/">Home</router-link>
// {/* 引用其他子应用 */}
// <router-link to="/vue">vue应用</router-link>
// <router-link to="/react">react应用</router-link>
<template>
  <div>
    <el-menu :router="true" mode="horizontal">
      {/* 基座中可以放自己的路由 */}
      <el-menu-item index="/">Home</el-menu-item>
      {/* 引用其他子应用 */}
      <el-menu-item index="/vue">vue应用</el-menu-item>
      <el-menu-item index="/react">react应用</el-menu-item>
    </el-menu>
    <router-view></router-view>
    <div id="vue"></div>
    <div id="react"></div>
  </div>
</template>



// main.js
// 来注册子应用
import Vue from 'vue';
import App from './App.vue';
import router from './router';
import elementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
Vue.use(elementUI);

import {registerMicroApps, start} from 'qiankun';
// 要注册的应用列表
// 比较好的是当应用加载的时候不需要自己写加载函数,有entry会默认去请求子应用的端口号
const apps = [
  {
    name: 'vueApp',
    // 默认会加载这个html 解析里面的js 动态的执行
    // 但是父应用去请求了子应用里的资源 所以子应用需要支持跨域(用的fetch)
    entry: '//localhost:10000', 
    container: "#vue", // 挂载到哪个元素
    activeRule: '/vue', //激活的路由
  },
  {
    name: 'reactApp',
    entry: '//localhost:20000', 
    container: "#react",
    activeRule: '/react',
  }
];
// 注册
registerMicroApps(apps);
// 开启
start();

new Vue({
  router,
  render: h => h(App)
}).$mount('#app');
/*
vue子应用 vue create qiankun-vue

*/
// router/index.js

const router = new VueRouter({
  mode: 'history',
  base: '/vue', // 默认以/vue开头
  routes
})

// vue.config.js
module.exports = {
  devServer: {
    port :10000,
    headers: {
      'Access-Control-Allow-origin' : '*'
    }
  },
  configureWebpack: {
    output: {
      library: 'vueApp',
      libraryTarget: 'umd'
    }
  }
}


// main.js
// 把子应用打包成类库,导出三个方法,三个方法必须是promise
import Vue from 'vue';
import App from './App.vue';
import router from './router';

let instance = null;
function render() {
  instance = new Vue({
    router,
    render: h => h(App)
  }).$mount('#app'); // 这里是挂载到自己的html中 基座会拿到这个挂载后的html 将其插入
}

if(window.__POWERED_BY_QIANKUN__) { // 动态添加publicPath
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
if(!window.__POWERED_BY_QIANKUN__) { // 默认独立运行
  render();
}
// 这样子应用的协议就设置好了
export async function bootstrap(props) {

}

export async function mount(props) {
  render(props);
}

export async function unmount(props) {
  instance.$destroy();
}

/*
react子应用 npm create-react-app qiankun-react
yarn add react-app-rewired 可以重写默认的react项目的配置
安装之后 所有的启动都以这个包名
"script": {
  "start" :"react-app-rewired start",
  "build": "react-app-rewired build"
}
*/
// 改完命令之后 增加配置文件 config-overrides.js 相当于配置文件的重写
module.exports = {
  webpack: (config) => {
    config.output.library = "reactApp";
    config.output.libraryTarget = "umd";
    config.output.publicPath = "http://localhost:20000/";
    return config;
  },
  devServer: (configFunction) => {
    return function (proxy, allowedHost) {
      const config = configFunction(proxy, allowedHost);
      config.port = "20000";
      config.headers = {
        "Access-Control-Allow-Origin": "*",
      };
      return config;
    };
  },
};

// index.js
import React from "react";
import ReactDOM from "react-dom";
import "./index.less";
import App from "./App";

function render() {
  ReactDOM.render(
    <React.StrictMode>
      <App />
    </React.StrictMode>,
    document.getElementById("root")
  );
}
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}
export async function bootstrap() {}

export async function mount() {
  render();
}

export async function unmount() {
  ReactDOM.unmountComponentAtNode(document.getElementById("root"));
}

实现

single-spa的实现

// rollup配置和简单使用
/*
npm install rollup rollup-plugin-serve -D
*/

// rollup.config.js
/*
"script" : {
  -c 表示要使用配置文件 -w监控文件变化重新打包
  "dev": "rollup -c -w"
}
*/
import serve from 'rollup-plugin-serve';
export default {
  input: './src/single-spa.js',
  output: {
    file: './lib/umd/single-spa.js',
    format: 'umd', // 会挂载到window上
    name: 'singleSpa', // 挂载到window上的名字
    sourcemap: true
  },
  plugins: [
    serve({
      openPage: '/index.html',
      contentBase: '',
      port: 3000
    })
  ]
}

// index.html 使用
<body>
  <script src="/lib/umd/single-spa.js"></script>
  <script>
    // registerApplication 会在路由匹配时 加载应用 start会挂载应用
    singleSpa.registerApplication('app1', 
      async () => {
        // 这个函数需要返回结果
        return {
          bootstrap: async () => {},
          mount: async () => {},
          unmount: async () => {}
        }
      },
      location => location.hash.startsWith("#/app1"),
      {store: {name: "zhuzhu", age: 28}}, // 自定义数据
    );
    singleSpa.start(); // 启动这个应用
  </script>
</body>
// src/single-spa.js
export { registerApplication } from './applications/app.js';
export {start} from './start';

// src/applications/app.js
const apps = []; // 用来存放所有的应用
// 参数分别表示: 应用名字 加载应用的方法 激活的时机 自定义属性
export function registerApplication(appName, loadApp, activeWhen, customProps) {
  apps.push({ // 这里就将应用注册好了
    name: appName,
    loadApp,
    activeWhen,
    customProps
  })
}

// src/start.js
export function start() {
  // 需要挂载应用
}