微前端

461 阅读17分钟

前言

当下前端领域,基于 vue、react、angular 的单页应用(spa)开发模式已经成为业界主流。随着时间推移以及应用的功能丰富,应用开始变得庞大臃肿,难以维护。每次开发、上线新需求时需要花费不少的时间来构建项目,而且有可能改一处而动全身,对开发人员的开发效率和体验都造成了不好的影响。因此将一个巨应用拆分为多个子应用势在必行。

什么是微前端

微前端(Micro-Frontends)是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。同时各个前端应用还可以独立运行、独立开发、独立部署。

微前端特点

1、与技术无关:主框架不限制接入应用的技术栈,微应用具备完全自主权。

2、各个子应用可以独立开发独立部署:微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新。

3、增量升级:在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略。

4、独立运行时:每个微应用之间状态隔离,运行时状态不共享。

微前端能解决什么问题

  • 拆分和细化:基于业务来拆分应用。每个应用都有一个自己的仓库,独立开发独立部署独立访问独立维护,还可以根据团队的特点选择适合自己的技术栈,极大的提升了开发人员的效率和体验,甚至其他团队误发布了一个半成品或有问题的应用也无关紧要。如果一个微前端已经准备好发布了,它就应该随时可发布,并且只由开发维护它的团队来定。

  • 整合历史系统:在不少的业务中,或多或少会存在一些历史项目,介于日常运营,这些系统需要结合到新框架中来使用还不能抛弃,对此我们也没有理由浪费时间和精力重写旧的逻辑。而微前端可以将这些系统进行整合,在基本不修改原来逻辑的同时来兼容新老两套系统并行运行。

  • 技术栈无关:通过基座应用,可以融合 vue,react,anglar,或者 js 开发的子项目。

实现微前端常用的技术方案

iframe

1、iframe 作为一项非常古老的技术,也可以用于实现微前端。通过 iframe,可以很方便的将一个应用嵌入到另一个应用中,而且两个应用之间的 css 和 javascript 是相互隔离的,不会互相干扰。

2、iframe 的优点:

  • 实现简单。
  • css 和 js 天然隔离,互不干扰。
  • 完全与技术栈无关。
  • 多个子应用可以并存。
  • 不需要对现有应用进行改造。

3、iframe 的缺点:

  • 用户体验不好,每次切换应用时,浏览器需要重新加载页面。
  • 浏览器前进后退问题:iframe页面刷新会重置,比如列表页跳到详情,刷新返回列表页,因为浏览器地址栏没变,刷新后重置为当前路径对应的页面。
  • UI 不同步,DOM 结构不共享。
  • 布局问题:必须给一个指定高度,否则塌陷
  • 全局上下文完全隔离,内存变量不共享,子应用之间通信、数据同步虽然可以使用 postMessage 实现,但是过程相对比较复杂。
  • 子应用切换时可能需要重新登录,体验不好。
  • 对 SEO 不友好。

single-spa

1、single-spa 是最早的微前端框架,兼容多种前端技术栈。

2、现在前端应用开发的主流模式基本上都是基于 react、Vue 的单页应用开发模式。我们需要维护一个路由注册表,每个路由对应各自的页面组件 url。切换路由时,如果是一个新的页面,动态获取路由对应的 js 脚本后,执行脚本并渲染出对应的页面;如果是一个已经访问过的页面,直接从缓存中获取已缓存的页面方法,执行并渲染出对应的页面。 在 single-spa 方案中,应用被分为两类:基座应用和子应用。基座是一个单独的应用,用于聚合子应用。而子应用就是需要被基座所聚合的应用。

3、single-spa 的优点:

  • 切换应用时,浏览器不用重载页面,提供和单页应用一样的用户体验。
  • 完全与技术栈无关。
  • 多个子应用可并存。
  • 生态丰富。

4、single-spa 的缺点:

  • 通信问题:只在注册微应用时给微应用注入状态信息,后续就不管了,通信只能用户自己实现。
  • 子应用间相同资源会重复加载。
  • 对微应用的侵入性太强,常见的打包优化基本上都没了比如:按需加载,首屏资源优化,css独立打包等。
  • 隔离:没有实现样式分离和js隔离,没有沙箱机制,不能动态加载js文件。

single-spa的使用

主应用

1、安装single-spa

npm i single-spa -d or yarn add single-spa

2、修改main.js

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(url){
  return new Promise((resolve, reject) => {
    let script = document.createElement('script');
    script.src = url;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
  })
}

 registerApplication({
    name: 'app1', 
    app: async () => {
      // 加载vue子模块
      await loadScript('http://localhost:10000/js/chunk-vendors.js');
      await loadScript('http://localhost:10000/js/app.js');
      return window.singleVue;
    },
    // 用户切换到/vue的路径下,我需要加载刚才定义的子应用
    activeWhen: location => location.pathname.startsWith('/vue')
})

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

3、修改App.js

<template>
  <div id="app">
    <router-link to="/vue">加载vue子应用</router-link>
    <!--子应用加载的位置-->
    <div id="vue"></div>
  </div>
</template>
子应用

1、安装singl-spa-vue

npm i single-spa-vue -d

2、修改vue.config.js

module.exports = {
  configureWebpack: {
    output: {
      library: 'singleVue',
      libraryTarget: 'umd' // 以umd形式打包出去
    },
    devServer: {
      port: 10000
    }
  }
}

3、修改main.js

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import singleSpa from 'single-spa-vue';
 
Vue.config.productionTip = false
const appOptions = {
  el: '#vue', // 挂载到父应用中的id为vue的标签中
  router,
  render: h => h(App)
}
const vueLifeCycle = singleSpa({
  Vue,
  appOptions
}) 
// 如果是父应用引用我,就会有这个属性
if(window.singleSpaNavigate){
  __webpack_public_path__ = 'http://localhost:10000/'
} else {
  // 如果不是父应用引用我
  delete appOptions.el;
  new Vue(appOptions).$mount('#app');
}
// 协议接入:我订好了协议,父应用会调用这些方法
export const bootstrap = vueLifeCycle.bootstrap;
export const mount = vueLifeCycle.mount;
export const unmount = vueLifeCycle.unmount;
 
// 我们需要父应用加载子应用,将子应用加载成一个个的lib给父应用使用
// 子应用需要导出 bootstrap mount unmount

qiankun

qiankun 是阿里开源的微前端框架,它是基于 single-spa 进行二次封装,在框架层面解决了使用 single-spa 时需要开发人员自己编写子应用加载、通信、隔离等逻辑的问题。

1、qiankun 的用法和 single-spa 基本一样,也分为 application 模式和 parcel 模式。

2、application 模式是基于路由工作的,它将应用分为两类:基座和子应用。基座需要维护一个路由注册表,根据路由的变化来切换子应用;子应用是独立的应用,需要提供生命周期方法供基座应用使用。parcel 模式和 application 模式相反,它与路由无关,子应用切换是手动控制的。

qiankun的使用

主应用

1、 安装qiankun

npm i qiankun or yarn add qiankun

2、注册微应用

  • 修改src/index.js注册微应用并启动
import React from "react";
import ReactDOM from "react-dom";
import {
  registerMicroApps,
  start,
  runAfterFirstMounted,
  addGlobalUncaughtErrorHandler,
  initGlobalState,
  MicroAppStateActions,
} from "qiankun";
import App from "./App";

const render = (porps) => {
  ReactDOM.render(
    <React.StrictMode>
      <App {...porps} />
    </React.StrictMode>,
    document.getElementById("main-root")
  );
};

render({ loading: true });

const appStart = () => {
  // 设置子应用首次加载loading效果
  const loader = loading => render({ loading });

  const routes = [
    {
      name: "reactApp",
      entry: "//localhost:8989",
      activeRule: "/dnhyxc/react",
      container: "#sub-app-viewport",
      loader,
      props: {
        info: "我是props参数",
        routerBase: "/dnhyxc/react", // 给子应用下发的基础路由
      },
    },
    {
      name: "vueApp",
      entry: "//localhost:8686",
      activeRule: "/dnhyxc/vue",
      container: "#sub-app-viewport",
      loader,
      props: {
        routerBase: "/dnhyxc/vue", // 给子应用下发的基础路由
      },
    },
  ];

  // 注册子应用
  registerMicroApps(routes, {
    beforeLoad: app => {}
    beforeMount: app => {}
    afterMount: app => {}
    afterUnmount: app => {}
  });

  start();

  // 微前端启动进入第一个子应用后回调函数
  runAfterFirstMounted(() => {});

  // 添加全局异常捕获
  addGlobalUncaughtErrorHandler((event) => {
    console.error("异常捕获", event);
    const { message } = event;
    const errorApp = [];
    routes.forEach((i) => {
      if (message && message.includes(i.name)) {
        errorApp.splice(0, 1, i);
      }
    });

    // 加载失败时提示
    if (
      message &&
      message.includes("died in status LOADING_SOURCE_CODE") &&
      errorApp.length &&
      window.location.pathname === errorApp[0].activeRule
    ) {
      render(failed);
    }
  });

  const initState = {
    AppName: "micro-react-main",
  };
  initGlobalState(initState);
};

const actions = initGlobalState({});
window.__MAIN_GLOBALSTATE_ACTIONS__ = actions;
actions.onGlobalStateChange((state, prev) => {
  // state: 变更后的状态; prev 变更前的状态
  console.log("[onGlobalStateChange - master]:", state, prev);
});

3、添加子应用容器
在src/App.js中添加子应用容器元素

const App = ({ loading, ...props }) => {
  return (
    <div className="app-main">
      <div>这里放主应用内容</div>
      <div className="app-sub">
        {/* 子应用容器 */}
        <div id="sub-app-viewport"></div>
      </div>
    </div>
  );
};

export default App;
React 子应用

1、在 src 目录新增 public-path.js:

if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

2、修改入口文件 /src/index.js:

import "./public-path";
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";

function render(props) {
  const { container } = props;
  ReactDOM.render(
      <App />// 为了避免根 id #root 与其他的 DOM 冲突,需要限制查找范围。
    container ? container.querySelector("#root") : document.querySelector("#root")
  );
}

if (!window.__POWERED_BY_QIANKUN__) {
  render({});
}

/**
 * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
 * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
 */
export async function bootstrap() {}

/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export async function mount(props) {
  render(props);
}

/**
 * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
 */
export async function unmount(props) {
  const { container } = props;
  ReactDOM.unmountComponentAtNode(
    container
      ? container.querySelector("#root")
      : document.querySelector("#root")
  );
}

/**
 * 可选生命周期钩子,仅使用 loadMicroApp 方式加载微应用时生效
 */
export async function update(props) {}
Vue子应用

1、在 src 目录新增 public-path.js 文件:

if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

2、修改入口文件 /src/main.js 文件:

import "./public-path";
import Vue from "vue";
import App from "./App.vue";

let instance = null;

Vue.config.productionTip = false;

function render(props = {}) {
  const { container } = props;

  instance = new Vue({
    render: (h) => h(App),
  }).$mount(container ? container.querySelector("#app") : document.getElementById("#app"));
}

// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap() {}
export async function mount(props) {
  render(props);
}
export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = "";
  instance = null;
}

3、修改打包配置 vue.config.js 文件:

const { name } = require("./package");
module.exports = {
  devServer: {
    headers: {
      "Access-Control-Allow-Origin": "*",
    },
  },
  configureWebpack: {
    output: {
      library: `${name}-[name]`,
      libraryTarget: "umd", // 把微应用打包成 umd 库格式
      jsonpFunction: `webpackJsonp_${name}`,
    },
  },
};

主子应用通信说明

1、一般来说,微前端中各个应用之间的通信应该是尽量少的(依赖于应用的合理拆分)。如果你发现两个应用间通信频繁,一般是拆分不合理造成的,这时需要将它们合并成一个应用。

2、qiankun 官方基于通信问题提供了一个简要的方案,思路是基于一个全局的 globalState 对象。这个对象由基座应用负责创建,内部包含一组用于通信的变量,以及两个分别用于修改变量值和监听变量变化的方法:setGlobalState 和 onGlobalStateChange,具体使用如下:

import { initGlobalState, MicroAppStateActions } from "qiankun";

const initialState = {};
const actions = initGlobalState(initialState);

export default actions;
  • 这里的 actions 对象就是我们说的 globalState,即全局状态。基座应用可以在加载子应用时通过 props 将 actions 传递到子应用内,而子应用通过以下语句即可监听全局状态变化:
actions.onGlobalStateChange (globalState, oldGlobalState) {...}
  • 修改全局状态:
actions.setGlobalState(...); 
  • 子应用也可以从主应用传递下来的 props 中获取到 setGlobalState 方法修改全局状态。
主子应用全局状态管理配置方式

1、qiankun 通过 initGlobalState, onGlobalStateChange, setGlobalState 实现主应用的全局状态管理,然后默认会通过 props 将通信方法传递给子应用。

  • 主应用
// main/src/main.js
import { initGlobalState } from "qiankun";
// 初始化 state
const initialState = {
  user: {}, // 用户信息
};
const actions = initGlobalState(initialState);
actions.onGlobalStateChange((state, prev) => {
  // state: 变更后的状态; prev 变更前的状态
  console.log(state, prev);
});
actions.setGlobalState(state);
actions.offGlobalStateChange();
  • 子应用
// 从生命周期 mount 中获取通信方法,props默认会有onGlobalStateChange和setGlobalState两个api
export function mount(props) {
  props.onGlobalStateChange((state, prev) => {
    // state: 变更后的状态; prev 变更前的状态
    console.log(state, prev);
  });
  props.setGlobalState(state);
}

2、除了上述通信方式以外,还可以直接通过 props 进行传递:

const apps = [
  {
    name: "reactApp",
    entry: "//localhost:8686",
    activeRule: "/dnhyxc/react",
    container: "#subapp-viewport",
    loader,
    props: {
      params: "我是主应用传递过来的参数",
      routerBase: "/dnhyxc/react", // 给子应用下发的基础路由
    },
  },
];
  • 通过 props 传递的参数,在子应用中,可以直接通过 props 获取到。

qiankun 实现原理

qiankun 实现的四个步骤

1、监听路由变化。

  • 监听 hash 路由可以直接使用 window.onhashchange 方法实现。

  • 监听 history 路由需要分两种情况:

    • history.go、history.back、history.forward:需要通过 popstate 事件实现。
    • pushState、replaceState 则需要通过函数重写的方式进行劫持。
window.addEventListener("popstate", () => {
  console.log("监听到 popstate 变化");
});

const rawPushState = window.history.pushState;
window.history.pushState = (...args) => {
  console.log("监听到 pushState 变化");
};

const rawReplaceState = window.history.replaceState;
window.history.replaceState = (...args) => {
  console.log("监听到 replaceState 变化");
};

2、匹配子路由。

  • 通过获取到当前的路由路径,再从 apps 中查找对应路径的应用。
// apps 就是在主项目中注册的子应用列表
const app = apps.find((i) => winddow.location.pathname === i.activeRule);

3、加载子应用。

  • 请求获取子应用的资源:HTML、CSS、JS。请求方式可以使用 fetch、ajax、axios 等。
const fetchResource = (url) => fetch(url).then((res) => res.text());

res.text()如下:

image.png 子应用的html代码已嵌套到主应用中,但子应用html代码未执行,所以看不到子应用内容。

4、渲染子应用。

  • 由于客户端渲染需要通过执行 JS 来生成内容,而浏览器出于安全考虑,innerHTML 中的 script 不会加载执行,要想执行其中的代码,需要通过 evel() 方法或者 new Function 手动加载子应用的script。
import { fetchResource } from "./fetchResource";

export const importHtml = async (url) => {
  const html = await fetchResource(url);
  const template = document.createElement("div");
  template.innerHTML = html;

  const scripts = template.querySelectorAll("script");

  // 获取所有 script 标签的代码
  function getExternalScripts() {
    return Promise.all(
      Array.from(scripts).map((script) => {
        const src = script.getAttribute("src");
        if (!src) {
          return Promise.resolve(script.innerHTML);
        } else {
          return fetchResource(src.startsWith("http") ? src : `${url}${src}`);
        }
      })
    );
  }

  // 获取并执行所有的 script 脚本代码
  async function execScripts() {
    const scripts = await getExternalScripts();

    // 手动构造一个 CommonJS 模块执行环境,此时会将子应用挂载到 module.exports 上。
    // 这种方式就可以不依赖子应用的名字了。
    const module = { exports: {} };
    const exports = module.exports;

    scripts.forEach((code) => {
      eval(code);
      // 这里能通过window["m-vue"]拿到子应用的内容
      // 是因为在子应用打包时通过 libraryTarget 打出了 umd 格式的包,最终会将 m-vue 挂载到 window 上
      // 使用 window 获取子应用的方式需要知道每个子应用的打包出来的名字,比较麻烦,因此不推荐该写法。
      // return window["m-vue"];
    });

    return module.exports;
  }

  return {
    template,
    execScripts,
  };
};

具体手写实现代码

1、index.js:

import { rewriteRouter } from "./rewriteRouter";
import { handleRouter } from "./handleRouter";

// 注册子应用方法
let _apps = [];

export const getApps = () => _apps;

export const registerMicroApps = (apps) => {
  _apps = apps;
};

export const start = () => {
  rewriteRouter();

  // 初始化时手动执行匹配
  handleRouter();
};

2、rewriteRouter.js:

import { handleRouter } from "./handleRouter";

/**
 * 1. 监视路由变化
 *  - hash 路由:使用 window.onhashchange 方法监视
 *  - history 路由:
 *    - history.go、history.back、history.forword 使用 popstate 事件进行监视。
 *    - pushState、replaceState 需要通过函数重写的方式进行劫持。
 */

let prevRoute = ""; // 上一个路由
let nextRoute = window.location.pathname; // 下一个路由

export const getPrevRoute = () => prevRoute;
export const getNextRoute = () => nextRoute;

export const rewriteRouter = () => {
  window.addEventListener("popstate", () => {
    // popstate 事件触发时,路由已经完成导航了,因此需要通过如下方式进行设置。
    prevRoute = nextRoute;
    nextRoute = window.location.pathname;
    handleRouter();
  });

  const rawPushState = window.history.pushState;
  window.history.pushState = (...args) => {
    prevRoute = window.location.pathname;
    rawPushState.apply(window.history, args); // 执行完这句代码之后,就改变了路由的历史纪录
    nextRoute = window.location.pathname;
    handleRouter();
  };

  const rawReplaceState = window.history.replaceState;
  window.history.replaceState = (...args) => {
    prevRoute = window.location.pathname;
    rawReplaceState.apply(window.history, args);
    nextRoute = window.location.pathname;
    handleRouter();
  };
};

3、handleRouter.js:

import { getApps } from "./index";
import { getPrevRoute, getNextRoute } from "./rewriteRouter";
import { importHtml } from "./importHtmlEntry";

// 处理路由变化
export const handleRouter = async () => {
  /**
   * 2. 匹配子应用
   *  - 获取到当前的路由路径
   *  - 从 apps 中查找对应的路径
   */
  const apps = getApps();

  // 获取上一个应用
  const prevApp = apps.find((i) => getPrevRoute().startsWith(i.activeRule));

  if (prevApp) {
    await unmount(prevApp);
  }

  // 获取下一个应用
  const app = apps.find((i) => getNextRoute().startsWith(i.activeRule));

  if (!app) return;

  /**
   * 1. 客户端渲染需要通过执行 JS 来生成内容
   * 2. 浏览器出于安全考虑,innerHTML 中的 script 不会加载执行,要想执行其中的代码,需要通过 evel() 方法或者 new Function 执行。
   */
  // container.innerHTML = html;
  const container = document.querySelector(app.container);

  // 4. 渲染子应用
  const { template, execScripts } = await importHtml(app.entry);

  container.appendChild(template);

  // 配置全局环境变量
  window.__POWERED_BY_QIANKUN__ = true;
  // 设置该全局变量用于解决子应用中图片无法加载出来的问题
  window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = `${app.entry}/`;

  const appExports = await execScripts();
  // 将从 module.exports 中获取到的 bootstrap、mount、unmount设置到 app 上。
  app.bootstrap = appExports.bootstrap;
  app.mount = appExports.mount;
  app.unmount = appExports.unmount;

  // 调用从 module.exports 中获取到的子应用中定义的 bootstrap 方法。
  await bootstrap(app);
  // 调用从 module.exports 中获取到的子应用中定义的 mount 方法。
  await mount(app);
};

async function bootstrap(app) {
  app.bootstrap && (await app.bootstrap());
}

async function mount(app) {
  app.mount &&
    (await app.mount({
      container: document.querySelector(app.container),
    }));
}

async function unmount(app) {
  app.unmount &&
    (await app.unmount({
      container: document.querySelector(app.container),
    }));
}

4、importHtmlEntry.js:

import { fetchResource } from "./fetchResource";

export const importHtml = async (url) => {
  const html = await fetchResource(url);
  const template = document.createElement("div");
  template.innerHTML = html;

  const scripts = template.querySelectorAll("script");

  // 获取所有 script 标签的代码
  function getExternalScripts() {
    return Promise.all(
      Array.from(scripts).map((script) => {
        const src = script.getAttribute("src");
        if (!src) {
          return Promise.resolve(script.innerHTML);
        } else {
          return fetchResource(src.startsWith("http") ? src : `${url}${src}`);
        }
      })
    );
  }

  // 获取并执行所有的 script 脚本代码
  async function execScripts() {
    const scripts = await getExternalScripts();

    // 手动构造一个 CommonJS 模块执行环境,将子应用挂载到module.exports上。此方式不依赖子应用的名字
    const module = { exports: {} };
    const exports = module.exports;

    scripts.forEach((code) => {
      eval(code);
      // 这里能通过window["m-vue"]拿到子应用的内容
      //是因为在子应用打包时通过 libraryTarget 打出umd格式的包,最终会将 m-vue 挂载到 window 上
      // 使用 window 获取子应用的方式需要知道每个子应用的打包出来的名字,比较麻烦,因此不推荐该写法。
      // return window["m-vue"];
    });

    return module.exports;
  }

  return {
    template,
    getExternalScripts,
    execScripts,
  };
};
  • webpack 以 umd 模式打包说明:
!(function (root, factory) {
  /**
   * roow 表示 window,
   * factory 表示回调函数:
   *    function () { 子应用代码 return { ... } 导出结果};
   */
  // 兼容 CommonJS 模块规范
  if(typeof exports === 'object' && typeof module === 'object') {
      module.exports = factory()
  } else if(typeof define === 'object' && define.amd) { // 兼容 AMD 模块规范
      define([], factory)
  } else if(typeof exports === 'object') { // 也是兼容 CommonJS 模块规范
      export['m-vue'] = factory() // 相当于导出m-vue对象
  } else { // 都不匹配的情况下挂到widow
      // window[xxx] = factory() 此时window就会有 m-vue 对象
      // 使用 window 获取子应用的方式需要知道每个子应用的打包出来的名字,比较麻烦,因此不推荐该写法。
      // 可以手动构造一个commonjs模块环境
      root['m-vue'] = factory()  
  }
})(window, function () {
   /**内部代码****
  // 最终返回导出的结果,比如下面简单的对象
  return {
    a: 1,
    b: 2,
  };
});

5、fetchResource.js:

export const fetchResource = (url) => fetch(url).then((res) => res.text());

qiankun源码

加载子应用资源

1、qiankun基于single-spa进行了一次封装,给出了一个更完整的应用加载方案,并将应用加载的功能装成了 npm 插件 import-html-entry

  • 该方案的主要思路是允许以 html 文件为应用入口,然后通过一个 html 解析器从文件中提取 js 和 css 依赖,并通过 fetch 下载依赖,于是在 qiankun 中你可以这样配置入口:
const apps = [
  {
    name: "react-app",
    entry: "//localhost:8686",
    container: "#sub-view",
    activeRule: "/dnhyxc/react",
  },
];
  • qiankun 会通过 import-html-entry 请求 http://localhost:8686,得到对应的 html 文件,解析内部的所有 script 和 style 标签,依次下载和执行它们,这使得应用加载变得更易用。

import-html-entry 源码实现

1、import-html-entry 暴露出的核心接口是 importHTML,用于加载 html 文件,它支持两个参数:

  • url:要加载的文件地址,一般是服务中 html 的地址。
  • opts:配置参数,opts 如果是一个函数,则会替换默认的 fetch 作为新的下载文件的方法,此时其返回值应当是 Promise;如果是一个对象,那么它最多支持四个属性:fetch、getPublicPath、getDomain、getTemplate,用于替换默认的方法。

2、importHTML 方法的主要逻辑如下:

export default function importHTML(url, opts = {}) {
  // 此处省略一万字...
  // 如果已经加载过,则从缓存返回,否则fetch回来并保存到缓存中
  return embedHTMLCache[url] || (embedHTMLCache[url] = fetch(url)
		.then(response => readResAsString(response, autoDecodeResponse))
		.then(html => {
		  // 对html字符串进行初步处理
		  const { template, scripts, entry, styles } =
		    processTpl(getTemplate(html), assetPublicPath);
		  // 先将外部样式处理成内联样式
		  // 然后返回几个核心的脚本及样式处理方法
		  return getEmbedHTML(template, styles, { fetch }).then(embedHTML => ({
				template: embedHTML,
				assetPublicPath,
				getExternalScripts: () => getExternalScripts(scripts, fetch),
				getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
				execScripts: (proxy, strictGlobal, execScriptsHooks = {}) => {
					if (!scripts.length) {
						return Promise.resolve();
					}
					return execScripts(entry, scripts, proxy, {
						fetch,
						strictGlobal,
						beforeExec: execScriptsHooks.beforeExec,
						afterExec: execScriptsHooks.afterExec,
					});
				},
			}));
		});
}
  • 上述代码中省略了一些参数预处理,从 return 语句开始,具体过程如下:

    • 检查是否有缓存,如果有,直接从缓存中返回。
    • 如果没有,则通过 fetch 下载,并字符串化。
    • 调用 processTpl 进行一次模板解析,主要任务是扫描出外联脚本和外联样式,保存在 scripts 和 styles 中.
    • 调用 getEmbedHTML,将外联样式下载下来,并替换到模板内,使其变成内部样式。
    • 返回一个对象,该对象包含处理后的模板,以及 getExternalScriptsgetExternalStyleSheetsexecScripts 等几个核心方法。

3、getExternalStyleSheets 方法解析:

export function getExternalStyleSheets(styles, fetch = defaultFetch) {
  return Promise.all(styles.map(styleLink => {
	if (isInlineCode(styleLink)) {
	  // if it is inline style
	  return getInlineCode(styleLink);
	} else {
	  // external styles
	  return styleCache[styleLink] ||
	  (styleCache[styleLink] = fetch(styleLink).then(response => response.text()));
	}
  ));
}
  • 该方法会遍历 styles 数组,如果是内联样式,则直接返回;否则判断缓存中是否存在,如果没有,则通过 fetch 去下载,并进行缓存。

4、getExternalScripts 方法原理与 getExternalStyleSheets 方法类似,具体代码如下。

// scripts是解析html后得到的<scripts>标签的url的数组
export getExternalScripts(scripts, fetch = defaultFetch) {
  return Promise.all(scripts.map(script => {
    return fetch(scriptUrl).then(response => {
        return response.text();
    }));
  }))
}

5、execScripts 是实现 js 隔离的核心方法,可以通过给定的一个假 window 来执行所有 script 标签的脚本,这样就是真正模拟了浏览器执行 script 标签的行为。伪代码如下:

export async execScripts(proxy) {
  // 上面的getExternalScripts加载得到的<scripts>标签的内容
  const scriptsTexts = await getExternalScripts(scripts)
  window.proxy = proxy;
  // 模拟浏览器,按顺序执行script
  for (let scriptsText of scriptsTexts) {
    // 调整sourceMap的地址,否则sourceMap失效
    const sourceUrl = '//# sourceURL=${scriptSrc}\n';
    // 通过iife把proxy替换为window, 通过eval来执行这个script
    eval(`
      ;(function(window, self){
        ;${scriptText}
        ${sourceUrl}
      }).bind(window.proxy)(window.proxy, window.proxy);
    `;)
  }
}

js 隔离机制

1、js 隔离机制是通过 execScripts 方法实现的,部分核心源码如下:

export function execScripts(entry, scripts, proxy = window, opts = {}) {
  ... // 初始化参数
  return getExternalScripts(scripts, fetch, error)
	.then(scriptsText => {
	  // 在proxy对象下执行脚本的方法
	  const geval = (scriptSrc, inlineScript) => {
	    const rawCode = beforeExec(inlineScript, scriptSrc) || inlineScript;
	    const code = getExecutableScript(scriptSrc, rawCode, proxy, strictGlobal);
        (0, eval)(code);
        afterExec(inlineScript, scriptSrc);
	  };
	  // 执行单个脚本的方法
      function exec(scriptSrc, inlineScript, resolve) { ... }
      // 排期函数,负责逐个执行脚本
      function schedule(i, resolvePromise) { ... }
      // 启动排期函数,执行脚本
      return new Promise(resolve => schedule(0, success || resolve));
    });
});
  • 这个函数的关键是定义了三个函数:geval、exec、schedule,其中实现 js 隔离的是 geval 函数内调用的 getExecutableScript 函数。可以看出,在调这个函数时,会把外部传入的 proxy 作为参数传入了进去,而它返回的是一串新的脚本字符串,这段新的字符串内的 window 已经被 proxy 替代,具体实现逻辑如下:
function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) {
  const sourceUrl = isInlineCode(scriptSrc)
    ? ""
    : `//# sourceURL=${scriptSrc}\n`;

  // 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上
  // 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy
  const globalWindow = (0, eval)("window");
  globalWindow.proxy = proxy;
  // TODO 通过 strictGlobal 方式切换切换 with 闭包,待 with 方式坑趟平后再合并
  return strictGlobal
    ? `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
    : `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`;
}
  • 上述核心代码就是;(function(window, self, globalThis){with(window){;scriptText\n{scriptText}\n{sourceUrl}}}).bind(window.proxy)(window.proxy,window.proxy, window.proxy);,它把解析出的 scriptText(即脚本字符串)用 with(window){}包裹起来,然后把 window.proxy 作为函数的第一个参数传进来,所以 with 语法内的 window 实际上是 window.proxy。这样,当在执行这段代码时,所有类似 let name = ‘dnhyxc’ 这样的语句添加的全局变量 name,实际上是被挂载到了 window.proxy 上,而不是真正的全局 window 上。当应用被卸载时,对应的 proxy 会被清除,因此不会导致 js 污染。而当你配置 webpack 的打包类型为 lib 时,你得到的接口大概如下:
const jquery = (function () {})();

2、如果你的应用内使用了 jquery,那么这个 jquery 对象就会被挂载到 window.proxy 上。不过如果你在代码内直接写 window.name = ‘dnhyxc’ 来生成全局变量,那么 qiankun 就无法隔离 js 污染了。

  • import-html-entry 实现了上述能力后,qiankun 要做的就很简单了,只需要在加载一个应用时为其初始化一个 proxy 传递进来即可,proxySandbox.ts 文件内容如下:
export default class ProxySandbox implements SandBox {
  ...
  constructor(name: string) {
    ...
    const proxy = new Proxy(fakeWindow, {
      set () { ... },
      get () { ... }
    }
  }
}
  • 每次加载一个应用,qiankun 就初始化这样一个 proxySandbox,传入上述 execScripts 函数中。
  • 在 IE 下,由于 proxy 不被支持,并且没有可用的 polyfill,所以 qiankun 退而求其次,采用快照策略实现 js 隔离。它的大致思路是,在加载应用前,将 window 上的所有属性保存起来(即拍摄快照);等应用被卸载时,再恢复 window 上的所有属性,这样也可以防止全局污染。但是当页面同时存在多个应用实例时,qiankun 无法将其隔离开,所以 IE 下的快照策略无法支持多实例模式。

css 隔离机制

1、qiankun 主要提供了两种样式隔离方案,一种是基于 shadowDom 的;另一种则是实验性的,思路类似于 Vue 中的 scoped 属性,给每个子应用的根节点添加一个特殊属性,用作对所有 css 选择器的约束。

2、开启样式隔离的语法如下:

registerMicroApps({
  name: 'app1',
  ...
  sandbox: {
    strictStyleIsolation: true // 使用 shadow dom 进行样式隔离
    // experimentalStyleIsolation: true // 通过添加选择器范围来解决样式冲突
  },
})
  • 当启用 strictStyleIsolation 时,qiankun 将采用 shadowDom 的方式进行样式隔离,即为子应用的根节点创建一个 shadow root。最终整个应用的所有 DOM 将形成一棵 shadow tree。我们知道,shadowDom 的特点是,它内部所有节点的样式对树外面的节点无效,因此自然就实现了样式隔离。但是这种方案是存在缺陷的。因为某些 UI 框架可能会生成一些弹出框直接挂载到 document.body 下,此时由于脱离了 shadow tree,所以它的样式仍然会对全局造成污染。具体实现示例如下:
<!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" />
    <title>shadow-dom</title>
    <style>
      h1 {
        color: red;
      }
    </style>
  </head>
  <body>
    <h1>shadow-dom</h1>

    <div id="subApp">
      <h1>子应用内容</h1>
    </div>

    <script>
      const subApp = document.getElementById("subApp");
      // 是否允许通过 js 获取 shadow-dom
      const shadow = subApp.attachShadow({ mode: "open" });
      const h1 = document.createElement("h1");
      h1.innerHTML = "我是通过 shadow dom 添加的内容,我的样式不会受外部影响";
      h1.style.color = "deeppink";
      shadow.appendChild(h1);
    </script>
  </body>
</html>
  • 此外 qiankun 也在探索类似于 scoped 属性的样式隔离方案,可以通过 experimentalStyleIsolation 来开启。这种方案的策略是为子应用的根节点添加一个特定的随机属性,如:
<div
  data-qiankun-asiw732sde
  id="__qiankun_microapp_wrapper__"
  data-name="module-app1"
></div>
  • 这种方式需要为所有样式前面都加上这样的约束:
- .app-main {
-   字体大小:14 px ;
- }

/* 使用如下写法代替上面的写法 */
+ div[data-qiankun-asiw732sde] .app-main {
+   字体大小:14 px ;
+ }
  • 经过上述替换,这个样式就只能在当前子应用内生效了。虽然该方案已经提出很久了,但仍然是实验性的,因为它不支持 @keyframes,@font-face,@import,@page(即不会被重写)。

qiankun缺点

  • 适配成本高,工程化,生命周期,静态资源路径,路由都要做适配;
  • css采用严格隔离会有问题,js沙箱在某些场景下执行性能下降严重;
  • 无法同时激活多个子应用,也不支持子应用保活;
  • 无法支持vite等esmodule脚本执行。

无界

无界微前端方案基于 webcomponent 容器 + iframe 沙箱,能够完善的解决适配成本、样式隔离、运行性能、页面白屏、子应用通信、子应用保活、多应用激活、vite 框架支持、应用共享等用户的核心诉求。

无界的使用

主应用
主应用不需要学习额外的知识,无界提供基于Vue封装的wujie-vue和基于React封装的wujie-react,开发者可以当普通组件一样加载子应用。

<WujieVue
  width="100%"
  height="100%"
  name="xxx"
  url="xxx"
  :sync="true"
  :fiber="true"
  :degrade="false"
  :fetch="fetch"
  :props="props"
  :plugins="plugins"
  :beforeLoad="beforeLoad"
  :beforeMount="beforeMount"
  :afterMount="afterMount"
  :beforeUnmount="beforeUnmount"
  :afterUnmount="afterUnmount"
></WujieVue>

子应用
子应用需要支持跨域,这是所有微前端框架运行的前提,之后子应用可以不做任何改造就可以在无界框架中运行。 子应用会根据是否保活、是否做了生命周期适配进入不同的运行模式。

image.png 其中保活模式单例模式重建模式适用于不同的业务场景,就算复杂点的单例模式用户也只是需要做一点简单的生命周期改造工作,可以说子应用适配成本极低。

无界优点
主应用使用成本低,子应用适配成本低。
首屏打开快
  • 目前大部分微前端只能做到静态资源预加载,等到子应用资源加载完打开时页面仍有不短的白屏时间,这部分白屏时间主要是子应用 js 的解析和执行。
  • 无界不仅能够做到静态资源的预加载,还可以做到子应用的预执行。
  • 预执行会阻塞主应用的执行线程,所以无界提供 fiber 执行模式采取类似 react fiber 的方式间断执行 js,每个 js 文件的执行都包裹在 requestIdlecallback 中,每执行一个 js 可以返回响应外部的输入,但是这个颗粒度是 js 文件,如果子应用单个 js 文件过大,可以通过拆包的方式降低体积达到 fiber 执行模式效益最大化。
运行速度快

子应用的 js 在 iframe 内运行,由于 iframe 是一个天然的 js 运行沙箱,所以无需采用 with ( fakewindow ) 这种方式来指定子应用的执行上下文,从而避免由于采用 with 语句执行子应用代码而导致的性能下降,整体的运行性能和原生性能差别不大。

原生隔离

无界实现了css沙箱和js沙箱的原生隔离,子应用不用担心污染问题。
CSS沙箱隔离
无界将子应用的dom放置在webComponent+shadowdom容器中,除了可继承的css属性外实现了应用间css的原生隔离。
js 沙箱隔离
无界将子应用的 js 放置在 iframe(js-iframe)中运行,实现了应用之间 window、document、location、history 的完全解耦和隔离。 js 沙箱和 css 沙箱连接
无界在底层采用 proxy + Object.defineproperty 的方式将 js-iframe 中对 dom 操作劫持代理到 webcomponent shadowRoot 容器中,开发者无感知也无需关心。

子应用保活

当子应用设置为保活模式切换子应用后仍然可以保持子应用的状态和路由不会丢失。

子应用嵌套

无界支持子应用多层嵌套,嵌套的应用和正常应用一致,支持预加载、保活、同步、通信等能力,需要注意的是内嵌的子应用 name 也需要保持唯一性,否则将复用之前渲染出来的应用。

多应用激活

无界支持一个页面同时激活多个子应用并且保持这些子应用路由同步的能力。

去中心化通信

无界提供多种通信方式:window.parent 直接通信、props 数据注入、去中心化 EventBus 通信机制

  1. 子应用 js 在和主应用同域的 iframe 内运行,所以 window.parent 可以直接拿到主应用的 window 对象来进行通信
  2. 主应用可以向子应用注入 props 对象,里面可以注入数据和方法供子应用调用
  3. 内置的 EventBus 去中心化通信方案可以让应用之间方便的直接通信

生命周期

无界提供完善的生命周期钩子供主应用调用:

  1. beforeLoad:子应用开始加载静态资源前触发
  2. beforeMount:子应用渲染前触发 (生命周期改造专用)
  3. afterMount:子应用渲染后触发(生命周期改造专用)
  4. beforeUnmount:子应用卸载前触发(生命周期改造专用)
  5. afterUnmount:子应用卸载后触发(生命周期改造专用)
  6. activated:子应用进入后触发(保活模式专用)
  7. deactivated:子应用离开后触发(保活模式专用)

vite 框架支持

无界子应用运行在 iframe 中原生支持 esm 的脚本,而且不用担心子应用运行的上下文问题,因为子应用读取的就是 iframe 的 window 上下文,所以无界微前端原生支持 vite 框架。