初探微前端

423 阅读10分钟

1 what?什么是微前端?

在日常的开发当中,在我们的项目里面通常会分成很多的模块,比如这张图里的首页,流程中心等模块,可能我们会把这些模块都放在一个应用项目里。这就会导致一些问题:

【暂时假装有图】

  • 应用大,打包构建的时候会变得很慢。在生产环境部署的时候,我们可能只有一个很小的改动,但所有的模块都需要打包,会在打包上浪费很多的时间。
  • 维护协同困难。当我们的项目做大时,人越来越多了,项目变得越来越不好维护,协同也变成了一个问题。

那这时候我们能够怎么去做呢?我们可以把我们一个应用里的模块都拆分成一个个的子应用,然后再把我们的这些子应用放到一个主应用中去运行,对于这些子应用我们可以交给不同的团队去管理它,最后在将它们组合起来即可。

微前端就是将不同的功能按照不同的维度拆分成多个子应用。通过主应用来加载这些子应用。微前端的核心就是在于拆与合。

2 why?为什要使用微前端?

  • 不同的团队在开发同一个应用时所用的技术栈可能不一样

    在一个公司里面,部门和团队一般都非常的多,这就会导致有的团队使用的技术栈是react,有的是vue,开发的时候将两门技术整合在一起时比较困难的,所以在传统开发模式下我们一般都用同一种技术栈。如果我们采用微前端的开发方式,将应用划分成一个个的子应用,将子应用打包成一个个lib,把这些lib放到主应用中去调用,这样我们就解决了对技术限制问题。

  • 不同的团队都要求独立开发,独立部署

    如果每一个人都强耦合在一起开发的,我们在开发部署的将会特别的麻烦和恶心,对于项目来说也不利于管理,所以说独立开发和部署是一个非常重要的问题。在微前端中每个团队管理自己的子应用,所以能够做到独立开发,独立部署。

  • 项目中老的应用需要保留

    面对老旧,庞大,过时的技术栈的项目代码,如果已经面临不得不重构时,时间充足的情况下是没有问题的,但要是急于交付,可能这个项目就会有很多很多的问题,通常我们都是一部分一部分的去替换,与此同时,如果又有新的需求被要求加到应用里去,对很多人来说这个是一个非常令人头疼的事情。使用微前端可以在不停止对应用新增的时候,同时逐步对应用进行修改升级。

3 how?怎样落地微前端?

实现微前端落地方案常见的有以下三种形式:

(1) 自由组织模式

通过约定或规范进行互联互调,可以借助服务端技术或其他技术能力实现,只要符合微前端三要素则成立,比如 Nginx 路由分发。

(2) 基座模式

通过一个中心基座容器,对应用进行管理,串联应用之间的连接通信。具体实现上有 qiankun 和 Single-SPA 框架等。

(3) 去中心模式

应用以模块形式进行导入导出,彼此之间都可以链接共享资源且相互独立。此方式较为依赖运行环境或语言特性,会存在较大的兼容性问题,比如 Web Components 、Webpcak 5 中的 Module Federation 或者模块映射等技术。

4 基座模式

4.1 Single-SPA
4.1.1 简介

WeChat882df772a0cfbfdf2df03de177476550.png 4.1.2 Single-SPA的使用

所涉及到的应用有:

父应用
  parent-vue
两个子应用
  child-vue
  child-react

构建vue子应用

vue create child-vue
npm install single-spa-vue

在main.js中引入single-spa-vue,并通过single-spa-vue来导出必要的生命周期。

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import singleSpaVue from 'single-spa-vue'

Vue.config.productionTip = false

const appOptions = {
  el: '#vue', // 挂载到父应用中的id为vue的标签中
  router,
  render: h => h(App)
}

// 如果是父应用调用子应用,动态设置子应用publicPath
if (window.singleSpaNavigate) {
  __webpack_public_path__ = 'http://localhost:10000/';
}
// 如果是子应用在浏览器上打开
if(!window.singleSpaNavigate) {
  delete appOptions.el;
  new Vue(appOptions).$mount('#app');
}
const vueLifeCycle = singleSpaVue({
  Vue,
  appOptions
})

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

配置子路由基础路径,防止在父应用中调用时路径不对。修改router/index.js文件

const router = new VueRouter({
  mode: 'history',
  base: '/vue',
  routes
})

配置库打包,在子应用项目中新建一个vue.config.js文件

module.exports = {
    configureWebpack: {
        output: {
            library: 'singleVue',
            libraryTarget: 'umd'
        },
        devServer:{
            port:10000
        }
    }
}

构建react子应用

npx create-react-app child-react
npm i single-spa-react
yarn add react-app-rewired

修改index.js文件

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import singleSpaReact from 'single-spa-react'

const reactLifecycles = singleSpaReact({
  React, // 主React对象,一般是暴露在window上或通过require('react') import React from 'react'引入。
  ReactDOM, //  主ReactDOMbject,可以通过 require('react-dom') 从'react-dom'中导入ReactDOM。
  rootComponent: App // (必填) 将被渲染的顶层React组件。只有在提供了loadRootComponent的情况下才可以省略。
});

// 如果是父应用应用
if (window.singleSpaNavigate) {
  window.__webpack_public_path__ = 'http://localhost:20000/';
}
if(!window.singleSpaNavigate) {
  ReactDOM.render(
    <React.StrictMode>
      <App />
    </React.StrictMode>,
    document.getElementById('root')
  );
}

// 我定义好了协议,父应用会调用这些方法
export const bootstrap = reactLifecycles.bootstrap;
export const mount = reactLifecycles.mount;
export const unmount = reactLifecycles.unmount;

打包配置

module.exports = {
    webpack: (config) => {
        config.output.library = `singleReact`;
        config.output.libraryTarget = "umd";
        config.output.publicPath = 'http://localhost:20000/'
        return config
    }
};

修改.env

SKIP_PREFLIGHT_CHECK=true
PORT=20000
WDS_SOCKET_PORT=20000

主应用搭建

vue create parent-vue
npm install single-spa

修改App.vue文件,将子应用挂载到id="vue"标签中

<template>
  <div id="app">
    <router-link to="/">主页</router-link>
    <br />
    <router-link to="/vue">去加载vue应用</router-link>
    <br />
    <router-link to="/react">去加载react应用</router-link>
    <!--子应用加载的位置-->
    <div id="vue"></div>
    <div id="raect"></div>
  </div>
</template>

修改main.js文件,注册子应用,需要我们手动的去插入script。

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import {registerApplication, start} from 'single-spa';
Vue.config.productionTip = falseasync function loadScript(url) {
  return new Promise((resolve, reject) => {
    let script = document.createElement('script');
    script.src = url;
    script.onload = resolve;
    script.onerror = reject;
    const firstScript = document.getElementsByTagName('script')[0]
    firstScript.parentNode.insertBefore(script, firstScript)
  })
}
​
// singleSpa缺陷 
// 1、不够灵活 不能动态加载js文件
// 2、样式不隔离 没有js沙箱的机制registerApplication('singleVue', 
  async () => {
    // systemJs相当于在浏览器里可以去引用es6模块
    await loadScript('http://localhost:10000/js/chunk-vendors.js')
    await loadScript('http://localhost:10000/js/app.js')
    return window.singleVue;
  },
  loctaion => loctaion.pathname.startsWith('/vue'), // 用户切换到/vue的路径下,我们需要加载刚才定义的子应用
)
​
registerApplication('singleReact', 
  async () => {
    // systemJs相当于在浏览器里可以去引用es6模块
    await loadScript('http://localhost:20000/static/js/bundle.js')
    await loadScript('http://localhost:20000/static/js/vendors~main.chunk.js')
    await loadScript('http://localhost:20000/static/js/main.chunk.js')
    return window.singleReact;
  },
  loctaion => loctaion.pathname.startsWith('/react'), // 用户切换到/vue的路径下,我们需要加载刚才定义的子应用
)
start();
new Vue({
  router,
  render: h => h(App)
}).$mount('#app')

运行结果

屏幕录制2021-12-09 下午1.50.16.gif

4.1.3 Single-SPA的缺点
  • 将多个子应用都集成在一个页面中,css和js都是很有可能产生冲突,Single-SPA没有做js沙箱和css隔离的处理
  • 不够简单,配置比较繁琐。
4.2 qiankun
4.2.1 简介-
  • 📦 基于 single-spa 封装,提供了更加开箱即用的 API。

  • 📱 技术栈无关,任意技术栈的应用均可 使用/接入,不论是 React/Vue/Angular/JQuery 还是其他等框架。

  • 💪 HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。

  • 🛡 样式隔离,确保微应用之间样式互相不干扰。

  • 🧳 JS 沙箱,确保微应用之间 全局变量/事件 不冲突。

  • ⚡️ 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。

  • 🔌 umi 插件,提供了 @umijs/plugin-qiankun 供 umi 应用一键切换成微前端架构系统。

4.2.2 qiankun的使用

所涉及到的项目有

父应用
  qiankun-base
二个子应用
  qiankun-react
  qiankun-vue

基座-主应用的搭建

vue create qinkun-base

为了我们的项目样式好看一些,我们在项目里面引入element-ui,并安装qiankun。

npm i element-ui -s
npm install qiankun

在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'
// 子应用配置列表
const apps = [
  {
    name: 'vueApp', // 应用的名字
    entry:'//localhost:10000', // 默认会加载这个html 解析里面的js 动态的执行型(子应用必须支持跨域)
    container: '#vue', // 我们要渲染到的容器名
    activeRule: '/vue' // 通过那一个路径来激活
  },
  {
    name: 'reactApp',
    entry:'//localhost:20000', // 默认会加载这个html 解析里面的js 动态的执行型(子应用必须支持跨域)
    container: '#react',
    activeRule: '/react',
    props: {a: 1}
  }
]

registerMicroApps(apps); // 注册应用
start(); // 开启应用
Vue.config.productionTip = false

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

修改App.vue里去预留dom节点,等待子应用被加载插入

<template>
  <div>
    <el-menu :router="true" mode="horizontal">
      <el-menu-item index="/">首页</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>

<style>
</style>

构建子应用

根据 qiankun 的协议需要导出 bootstrap/mount/unmount 三个引用生命周期钩子函数用于父应用加载子应用时执行,另外父应用会在 window 上添加 POWERED_BY_QIANKUN 属性用于子应用区分当前是否被父应用加载,还是单独加载。所有子应用就围绕钩子函数和属性做相应配置, 各技术技术栈下处理方式基本一致。

构建qiankun-vue子应用

vue create qinkun-vue

修改main.js文件

import Vue from 'vue'
import App from './App.vue'
import router from './router'

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

// 如果是qiankun使用的话,那么乾坤会动态的去注入这些路径
if(window.__POWERED_BY_QIANKUN__){
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
// 如果它是独立运行
if(!window.__POWERED_BY_QIANKUN__){render()}

export async function bootstrap() {};

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

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

在项目下新建vue.config.js打包配置

module.exports = {
    devServer:{
        port:10000,
        headers:{
            'Access-Control-Allow-Origin':'*' // 由于子应用被父应用 fetch 加载,需要允许跨域
        }
    },
    configureWebpack:{
        output:{
            library:'vueApp', // 定义一个全局使用的名称变量
            libraryTarget:'umd' // 设置library的暴露方式,使用 umd 让被webpack打包出来的文件在加载时兼容性更强
        }
    }
}

构建qiankun-react子应用

npx create-react-app qiankun-react

为了我们能手动自己配置webpack,我们需要增加一个插件

yarn add react-app-rewired

修改package.json

 "scripts": {
    "start": "react-app-rewired start",
    "build": "react-app-rewired build",
    "test": "react-app-rewired test",
    "eject": "react-app-rewired eject"
  }

修改index.js文件

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import {BrowserRouter as Router, Route, Routes, Link} from 'react-router-dom';
import App from './App';
function render() {
  ReactDOM.render(
    <Router basename={window.__POWERED_BY_QIANKUN__ ? '/react' : '/'}>
      <Link to="/">首页</Link>
      <Link to="/about">关于</Link>
    <Routes>
        <Route path="/"  exact={true} element={<App />} />
        <Route path="/about" element={<div>关于</div>} />
    </Routes>
</Router>,
    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"));
}

配置启动 config-overrides.js

module.exports = {
    webpack: (config) => {
        config.output.library = `reactApp`;
        config.output.libraryTarget = "umd";
        config.output.publicPath = 'http://localhost:20000/'
        return config
    },
    devServer: function (configFunction) {
        return function (proxy, allowedHost) {
            const config = configFunction(proxy, allowedHost);
            config.headers = {
                "Access-Control-Allow-Origin": "*",
            };
            return config;
        };
    },
};

新建.env文件

SKIP_PREFLIGHT_CHECK=true
PORT=20000
WDS_SOCKET_PORT=20000

效果图展示

屏幕录制2021-12-08 下午4.48.22.gif

4.3 为什么不使用iframe

微前端基座模式的理念非常的像iframe,中有一篇对于微前端Why Not Iframe的思考,这里贴一下里面的优缺点:

  • iframe 提供了浏览器原生的硬隔离计划,不论是款式隔离、 js 隔离这类问题通通都能被完满解决。
  • url 不同步。浏览器刷新 iframe url 状态失落、后退后退按钮无奈应用。
  • UI 不同步,DOM 构造不共享。设想一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时咱们要求这个弹框要浏览器居中显示,还要浏览器 resize 时主动居中。
  • 全局上下文齐全隔离,内存变量不共享。iframe 内外零碎的通信、数据同步等需要,主利用的 cookie 要透传到根域名都不同的子利用中实现免登成果。
  • 慢。每次子利用进入都是一次浏览器上下文重建、资源从新加载的过程。

所以在微前端的开发中,通常都不使用iframe进行开发。

5. 如何自己实现CSS隔离方案

关于css的隔离问题,qiankun以及帮我们进行了处理,Single-SPA没有去帮我们处理,如果需要我们自己去处理的话可以怎么做呢?

5.1 子应用之间的隔离样式隔离

动态样式表。A,B两个应用分别有自己样式,那么我们在加载A的时候使用A的样式,使用B时移除A的使用B的。

5.2 父子应用的样式隔离

  • BEM(Block Element Modifier)约定项目前缀。(可能存在不遵守约定的)
  • CSS-Modules 打包时生成不冲突的选择器名。(这个是最常用的,它可以在编译的时候给每个样式表后面加一个随机串,这个串不冲突)
  • css-in-js。(不建议使用,样式多了不太好管理)
  • Shadow DOM 真正意义上的隔离。
<!--Shadow DOM -->
<body>
    <div>
        <p>hello</p>
        <div id="shadow"></div>
    </div>
    <script>
        let shadowDOM = shadow.attachShadow({mode:'closed'}); // 外界无法访问
        let pElm = document.createElement('p');
        pElm.innerHTML = 'world';
        let styleElm = document.createElement('style');
        styleElm.textContent = `
            p{color: red}
        `
        shadowDOM.appendChild(styleElm);
        shadowDOM.appendChild(pElm);

        // document.body.appendChild(pElm);
        // react 项目 弹框
    </script>
</body>

WeChat8a7e27ab780884b13b4002823fb1a0ab.png

6. qiankun里的两种沙箱机制

在多个应用中,任何应用去访问修改window的属性,都有可能造成window的污染。而沙箱就是创造一个干净的环境给子应用使用,当切换子应用时,可以选择丢弃或者恢复属性。

这里介绍两种实现沙箱的方法,也就是qiankun里的使用的沙箱,快照沙箱和代理沙箱。

6.1 快照沙箱

快照沙箱从名字上来看就是一张记录里某一时刻状态的照片,通常会在应用沙箱挂载或卸载时记录快照,在切换时依据快照恢复环境 (无法支持多实例)。

<body>
    <script>
        class SnapshotSandbox {
            constructor() {
                this.proxy = window; // window属性
                this.modifyPropsMap = {}; // 记录在window上的修改
                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.active()
            console.log(a,b,c);
        })(sandbox.proxy);
    </script>
</body>

结果展示:

WechatIMG1869.png

总结快照沙箱主要分成4步进行:

  1. 激活时将当前window属性进行快照处理
  2. 失活时用快照中的内容和当前window属性作比较
  3. 如果属性发生变化保存到modifyPropsMap中,并用快照还原window属性
  4. 再次激活时,再次进行快照,并用上次修改的结果还原window

6.2 代理沙箱

使用Proxy对象用于创建对象的代理,实现基本操作的拦截和自定义,用fakeWindow记录修改的window相关的属性,获取的时候如果fakeWindow中没有就从window上拿。

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);

结果展示:

EDDA459C-6B7A-414E-A186-A6036350B0EF.png