微前端实现原理研究总结

38 篇文章 2 订阅
18 篇文章 1 订阅

前言

前段时间研究了一下微前端的实现原理,总结了一些实现的关键点

微前端实现方案

  • iframe:浏览器兼容性好,实现起来简单。但是缺点也明显,比如路由状态丢失,通信困难

  • web component:浏览器兼容性差

  • SPA:当下比较流行的方案,比如qiankunsingle-spa

本文主要是研究SPA这种方案

子应用生命周期

子应用需要导出三个生命周期函数,用来给主应用进行初始化,分别如下:

  • bootstrap:初始化子应用前,你可以在这个生命周期函数中为子应用做一些前期的准备工作

  • mount:初始化子应用,在这个阶段你应该对子应用进行初始化

  • unmount:销毁子应用,在这个阶段你需要对子应用进行销毁,或者是销毁一些具有副作用的代码(比如定时器)

这三个生命周期函数只有在微前端(依附于主应用)的环境下才会被执行,如果是单独启动项目的时候是不会被执行的

改写子应用

我们需要对子应用进行一些改写。我们以vue3为例,主要是修改入口文件的内容。

  • 区分微前端环境和单独启动项目环境。在微前端环境下,主应用会在window全局环境下设置一个标志位用标识当前是微前端环境,我们可以通过这个标志位来区分微前端环境和单独启动项目环境

  • 在子应用启动的时候,如果是微前端环境,我们需要在mount钩子函数中初始化应用,如果是单独启动项目,我们需要立刻初始化应用。

  • 导出子应用的三个生命周期钩子函数

改写后的代码如下:

import { App, createApp } from "vue";
import AppComponent from "./App.vue";
import router from "./router";
import store from "./store";

let instance: App | null;

function render() {
  instance = createApp(AppComponent);

  instance.use(store).use(router).mount("#app");
}

if (!(window as any).__MICRO_WEB__) {
  render();
}

export function bootstrap() {
  console.log("bootstrap");
}

export function mount() {
  console.log("mount");
  render();
}

export function unmount() {
  console.log("unmount");
  instance?.unmount();
}

子应用打包

我们在前面改写了子应用,并导出了子应用的三个生命周期函数,目的是为了可以让主应用可以访问这三个生命周期函数,所以我们需要对子应用的打包进行修改。以vue3为例,在vue.config.js中修改,修改后的代码如下:

const { defineConfig } = require("@vue/cli-service");
module.exports = defineConfig({
  // ..
  devServer: {
    port:9094
    headers: {
      "Access-Control-Allow-Origin": "*",
    },
  },
  configureWebpack: {
    output: {
      libraryTarget: "umd",
      filename: "[name].js",
      library: "vue3",
    },
  },
});
  • 首先我们在headers中设置了"Access-Control-Allow-Origin": "*",这个是为了解决在开发环境下跨域的问题。因为在开发环境下,主应用和子应用都是在不同的端口号中启动的

  • 然后就是output打包输出的修改。我们先来分析一下libraryTargetfilenamelibrary这三个属性的作用

    • libraryTarget:文件输出的格式。可以是cjsamdumd的格式,es module格式只在最新版的webpack中支持。我们选择umd这种比较通用的模块格式,主要是为了可以在window全局环境下访问导出的三个生命周期函数
    • filename:输出的文件名。[name]是一个占位符,跟入口名称有关。因为vue-cli会打包出多个文件,所以不能直接写死输出的文件名,打包的时候会报错
    • library:挂载在window全局环境下的变量名,我们可以通过这个变量名去访问三个生命周期函数。vue3.bootstrap()vue3.mount()vue3.unmount()

主应用中注册子应用

子应用需要在主应用中进行注册,用来告诉主应用有那些子应用,注册代码结构如下:

export default [
  {
    name: "vue3",
    entry: "//localhost:9004/",
    container: "#micro-container",
    activeRule: "/vue3",
  },
  // ...
];

我们来分析一下每个key所代表的的含义

  • name:子应用名称,这个名称需要跟前面子应用打包配置中的output.library保持一致。目的是为了告诉主应用可以通过这个变量名去访问子应用的三个生命周期函数
  • entry:子应用的入口地址,开发环境就填写开发环境地址,生产环境就填写生产环境地址
  • container:子应用的父容器。
  • activeRule:激活子应用的路由地址。假设当前地址是http://localhost:8080/vue3,那么vue3这个子应用将会被激活,进行初始化

主应用和子应用的路由模式

这个问题主要是针对单页面应用,主要是vuereactangular这些框架

主应用使用的是HTML5 history路由模式,那么子应用就只能使用hash history路由模式。

如果主应用和子应用都采用了相同的路由模式,那么就会产生冲突

主应用路由拦截

主应用需要监听地址栏的url来激活对应的子应用,但是主应用采用的是HTML5 history路由模式,没有相关的事件来监听url的变化

但是我们可以知道vue中使通过history.pushState方法来修改地址栏的url,所以我们可以通过拦截改写history.pushState方法,添加一些我们自定义的逻辑,这也是一种常见做法(比如vue2中数组的响应式,就是通过改写方法实现的)

代码如下:

const patchRouter = (globalEvent: Function, eventName: string) => {
  return function () {
    const e = new Event(eventName);
    // @ts-ignore
    globalEvent.apply(this, arguments);
    window.dispatchEvent(e);
  };
};

export const rewriteRouter = () => {
  window.history.pushState = patchRouter(
    window.history.pushState,
    "micro_push"
  );
  window.history.replaceState = patchRouter(
    window.history.replaceState,
    "micro_replace"
  );

  window.addEventListener("micro_push", turnApp);
  window.addEventListener("micro_replace", turnApp);
  window.addEventListener("popstate", turnApp);
};

从上面可以看见,主要做了两件事

  • 改写了pushStatereplaceState这两个方法,改写后的方法主要做了两件事

    • 执行原来的方法
    • 派发自定义事件
  • 监听派发的自定义事件(micro_pushmicro_replace)和popstate事件就可以知道地址栏的url发生了改变

主应用获取子应用并执行生命周期函数

主应用路由拦截修改完成之后,我们就可以通过监听事件来知道地址栏的url发生变化,从而可以根据当前的地址来获取子应用

代码如下:

// 查找子应用
export const findApp = (activeRule: string) => {
  // getAppList获取的是注册的子应用列表
  return getAppList().find((item) => item.activeRule === activeRule);
};

export const turnApp = async () => {
  const pathname = window.location.pathname.replace(/\/$/, "");
  //   上一个应用对应地址
  const oldAppPath = (window as any).__CURRENT_SUB_APP__;

  if (oldAppPath === pathname) {
    return;
  }

  // 获取上一个应用
  const prevApp = findApp(oldAppPath);

  prevApp?.unmount?.();

  // 获取下一个应用
  const nextApp = findApp(pathname);

  (window as any).__CURRENT_SUB_APP__ = pathname;

  if (nextApp) {
    // 加载并解析子应用,见下文`主应用加载并解析子应用`章节
    const app = await loadHtml(nextApp);

    app.bootstrap?.();

    app.mount?.();
  }
};

主应用加载并解析子应用

主应用加载并解析子应用分为如下几个步骤:

获取html文件内容

根据注册的子应用地址,发送ajax请求获取html文件内容。特别注意的是,我们获取的是html文件内容,而不是javascript文件内容

子应用的html文件内容如下(vue3 为例):

<!DOCTYPE html>
<html lang="">
  <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="/favicon.ico" />
    <title>vue3</title>
    <script defer src="/chunk-vendors.js"></script>
    <script defer src="/app.js"></script>
  </head>
  <body>
    <noscript>
      <strong
        >We're sorry but vue3 doesn't work properly without JavaScript enabled.
        Please enable it to continue.</strong
      >
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>

解析html内容

解析html内容,获取所有的javascript代码块或者javascript路径地址

javascripthtml文件中存在两种形式,一种是代码块,一种是路径地址

代码块

<script>
  console.log(11);
</script>

路径地址

<script defer src="/app.js"></script>

获取所有的javascript代码块或者javascript路径地址的思路如下:

  • 创建一个div元素
  • html字符串添加到div元素中
  • 通过div.querySelectorAll("script")获取的到所有script标签
  • script标签进行分类,如果是存在src属性,说明是javascript路径地址,反之则是javascript代码块
  • javascript路径地址,则需要判断是绝对路径还是相对路径,如果是相对路径,需要根据对应的子应用的入口地址拼接出完整的路径地址
  • javascript代码块,需要去掉script标签,只获取script标签中的内容

解析代码如下:

const parseHtml = async (htmlStr: string, app: AppItem) => {
  const div = document.createElement("div");
  div.innerHTML = htmlStr;
  const scriptUrl: string[] = [];
  const script: string[] = [];
  const scriptElements = root.querySelectorAll("script");
  for (let i = 0; i < scriptElements.length; i++) {
    const element = scriptElements[i];
    const src = element.getAttribute("src");
    if (!src) {
      // javascript代码块
      script.push(element.innerHTML);
    } else {
      // 路径地址
      if (src.startsWith("http")) {
        // 绝对路径
        scriptUrl.push(src);
      } else {
        // 相对路径
        scriptUrl.push(`http:${app.entry}/${src}`);
      }
    }
  }

  return { scriptUrl, script };
};

获取javascript文件内容

根据javascript路径地址发送ajax请求获取javascript文件内容

解析完html文件内容之后,我们就可以获取的到javascript代码块内容和javascript路径地址。此时我们需要做的就是发送请求获取javascript路径地址所对应的文件内容,因为我们最终需要的是javascript代码块,然后执行这些javascript代码块的内容

代码如下:

export const loadHtml = async (app: AppItem) => {
  const htmlStr = await fetchResource(app.entry);
  // ...
  const { scriptUrl, script } = await parseHtml(htmlStr, app);

  const fetchScripts = await Promise.all(scriptUrl.map(fetchResource));

  const allScript = [...script, ...fetchScripts];

  // ...

  return app;
};

html文件内容添加到子应用容器中

在获取完所有的javascript代码块之后,执行javascript代码块之前,我们还需要做的操作是把html字符串添加到子应用容器当中

代码如下:

export const loadHtml = async (app: AppItem) => {
  const htmlStr = await fetchResource(app.entry);
  // ...
  const ct = document.querySelector(app.container);

  if (!ct) {
    throw new Error("容器不存在,请检查");
  }

  ct.innerHTML = htmlStr;

  // ...
  return app;
};

html文件内容中包含了metatitle等额外的标签,通过innerHTML的方式添加进去不会有什么影响,scriptlink等标签也不会去加载资源

执行 js 代码

所有东西就绪之后,接下来就是执行我们所获取的js代码块。js代码块是字符串,我们可以通过evalnew Functionscript标签的形式执行js字符串,这里更为推荐使用new Function的形式执行

js字符串执行的过程中,我们需要考虑一个问题,就是子应用与子应用之间会不会相互影响。比如说 A B 子应用同时使用或者依赖了window的某个全局属性,当 A 修改了这个全局属性时,会导致 B 受到了影响。为了避免子应用之间相互影响,我们需要一个沙箱环境执行js代码

沙箱环境可通过常规 diff 对比proxy去实现

常规 diff 对比

常规 diff 对比流程如下:

  • 通过new Map()创建一个沙箱快照,主要用来保存window原有的状态

  • 在沙箱被激活的时候,遍历window上面的所有属性和方法,并保存到沙箱快照中

  • 沙箱被销毁的时候,遍历window上面的所有属性和方法,对比快照的属性和方法,如果不一致就还原为快照中保存的属性和方法

代码如下:

// 快照沙箱
// 缺点:不支持多实例

// window上面有些属性是不能进行set的
const list = ["window", "document"];

const shouldProxy = (key: string) => {
  return window.hasOwnProperty(key) && !list.includes(key);
};

export class SnapShotSandbox {
  // 代理对象
  proxy = window;
  // 创建一个沙箱快照
  snapshot: Map<any, any> = new Map();
  constructor() {
    this.active();
  }
  // 沙箱激活
  active() {
    // 遍历全局环境
    for (const key in window) {
      if (shouldProxy(key)) {
        this.snapshot.set(key, window[key]);
      }
    }
  }
  // 沙箱销毁
  inactive() {
    for (const key in window) {
      if (shouldProxy(key)) {
        if (window[key] !== this.snapshot.get(key)) {
          // 还原操作
          window[key] = this.snapshot.get(key);
        }
      }
    }
  }
}

这种方式实现的沙箱有两个弊端,分别如下:

  • 不支持多实例

  • window上面有些属性是不能进行set操作的,比如windowdocument

proxy

proxy代理步骤如下:

  • 新增一个缓存对象,用来缓存set操作设置的值

  • 在沙箱被激活的时候,通过Proxy去代理window对象

    • get操作中,根据key从缓存对象中获取对应的值,如果不存在,就从window中获取。如果值是一个函数,需要绑定thiswindow,然后返回函数,如果是一个属性,直接返回即可
    • set操作中,把设置的值存储在缓存对象中,然后返回true,表示设置成功
  • 沙箱被销毁的时候,清空缓存对象的值

代码如下:

export class ProxySandbox {
  proxy!: Window & typeof globalThis;
  defaultValue: Record<string, any> = {};
  constructor() {
    this.active();
  }
  active() {
    this.proxy = new Proxy(window, {
      get: (target, key: any) => {
        const value = this.defaultValue[key] ?? target[key];
        if (typeof value === "function") {
          return value.bind(target);
        }
        return value;
      },
      set: (target, key: any, value: any) => {
        this.defaultValue[key] = value;
        return true;
      },
    });
  }

  inactive() {
    this.defaultValue = {};
  }
}

Proxy沙箱环境缺点就是存在兼容性问题,比如ie等旧版本浏览器不兼容。

我们可以将Proxy常规 diff 对比这两种方式结合使用。优先使用Proxy,如果浏览器不支持Proxy,就降级使用常规 diff 对比

沙箱环境执行 js 代码

经过上面的沙箱环境的准备,我们就可以使用沙箱环境执行js代码。

实现流程如下:

  • 初始化沙箱

  • js字符代码包裹一层立即执行函数,函数形参就是window,实参为代理对象

代码如下:

export const performScriptForEval = (script: string, app: AppItem) => {
  if (!app.proxy) {
    app.proxy = new ProxySandbox();
  }
  const global = app.proxy?.proxy ?? window;
  (window as any).proxy = global;
  const scriptText = `
    ((window) => {
        try {
            ${script}
        } catch (error) {
            console.error('run script error: ' + error)
        }
        return window['${app.name}']
    })(window.proxy)
      `;

  const ret = eval(scriptText);

  if (isLifeCycle(ret)) {
    app.bootstrap = ret.bootstrap;
    app.mount = ret.mount;
    app.unmount = ret.unmount;
  }
};

export const loadHtml = async (app: AppItem) => {
  const htmlStr = await fetchResource(app.entry);
  // ...
  const { scriptUrl, script } = await parseHtml(htmlStr, app);

  const fetchScripts = await Promise.all(scriptUrl.map(fetchResource));

  const allScript = [...script, ...fetchScripts];

  allScript.forEach((item) => {
    performScriptForEval(item, app);
  });
  // ...

  return app;
};

css 样式隔离

为了防止子应用之间的 css 样式会被相互影响,css 样式也需要进行样式隔离

css 样式隔离有三种方案,分别是css modulesshadow dommini-css-extract-plugin

css modules

这种方式需要借助打包工具实现,比如webpackcss-loader。其原理就是将类名转化为一个唯一的hash值,这样子就不存在样式冲突的问题了

下面以css-loader为例

webpack配置如下,关键就是把modules设置为true

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/i,
        loader: "css-loader",
        options: {
          modules: true,
        },
      },
    ],
  },
};

使用形式如下:

.c1 {
  color: red;
}
import style from "./style.css";
const div = document.getElementById("div");
div.className = style.c1;

shadow dom

关于shadow dom的详细介绍,可以点击这里查看

shadow dom是实现Web components的一个重要属性,存在兼容性的问题

mini-css-extract-plugin

mini-css-extract-plugin是一个webpack的插件,主要作用是把css样式提取出来,放置到一个单独的文件,这样子就可以通过link标签加载样式文件。

子应用加载的时候通过动态添加link标签加载样式,子应用销毁的时候移除对应的link标签即可。这样子也可以达到样式隔离

主应用和子应用通信

主应用和子应用通信有两种方案,分别是propsCustomEvent

除此之外,还可以借助localStoragesessionStorage等其他方式来进行通信

我们只讨论propsCustomEvent这两种方案

props

我们知道,子应用中有三个生命周期函数,主应用在调用子应用的生命周期函数时,可以通过传参的形式实现父子应用通信

代码如下:

子应用

let store = null;

const render = () => {
  console.log(store);
  // ...
};

export function bootstrap(store) {
  store = store;
}

export function mount() {
  render();
}

主应用

const store = {
  state: { age: 1 },
};
export const turnApp = async () => {
  // ...
  const nextApp = findApp(pathname);
  // ...

  if (nextApp) {
    // ...

    app.bootstrap?.(store);

    app.mount?.();
  }
};

CustomEvent

CustomEvent自定义事件。主应用派发事件,子应用监听事件。或者主应用监听事件,子应用派发事件。这样子就可以实现双向通信。实际上就是发布订阅模式

代码如下:

// 派发事件
const event = new CustomEvent("test-event", {
  detail: { name: "张三" },
});
window.dispatchEvent(event);

// 监听事件
window.addEventListener("test-event", (data: any) => {
  console.log(data.detail);
});

我们可以在主应用中对代码进行一个统一的封装,然后在挂载到window上面,这样子主应用和子应用都可以访问,同时也方便进行管理

子应用之间通信

子应用之间的通信跟主应用和子应用通信的通信方案是一致的。都可以使用propsCustomEvent

不同的是,当使用props时,数据的传递路径为 子 -> 父 -> 子

全局状态管理

对于一些全局的共享数据,比如用户的登录信息。我们需要一个全局的状态管理工具。

如果我们的所有项目都是用vue,那么我们就可以直接使用vuex

如果每个子项目所使用的的框架不一样,vuex不是一种通用的解决方案,我们需要自己实现一个全局的状态管理工具。

我们通过发布订阅模式实现一个全局的状态管理工具,代码如下:

export const createStore = (initData = {}) => {
  let store = initData;
  const observers = [];
  const getStore = () => {
    return store;
  };
  const updateStore = (newValue) =>
    new Promise((res) => {
      if (newValue !== store) {
        const oldValue = store;
        store = newValue;
        res(store);

        observers.forEach((fn) => fn(newValue, oldValue));
      }
    });

  const subscribeStore = (fn) => {
    observers.push(fn);
  };
  return { getStore, updateStore, subscribeStore };
};

在主应用中,我们创建这个store,并且把它挂载到window下(当然也可以通过传参的形式,把这个store传给子应用)

const store = createStore();

window.store = store;

然后我们就可以在主应用或者子应用中的任意位置获取得到这个store。并且通过getStore获取store的数据,updateStore更新store的数据,subscribeStore订阅store的数据变化

缓存子应用

我们在切换子应用的时候,每次都会重新请求子应用的内容,然后解析内容。我们可以在子应用一次加载和解析完成之后,缓存对应的内容。下次在加载和解析子应用的时候,可以缓存的结果,减少网络请求的次数,提高子应用的显示速度,提高系统的性能

代码如下:

// 缓存子应用
const cache: Record<string, any> = {};

export const handleResource = async (app: AppItem) => {
  if (cache[app.name]) {
    return cache[app.name];
  }
  const html = await fetchResource(app.entry);

  const { scriptUrl, script } = await parseHtml(html, app);

  const fetchScripts = await Promise.all(scriptUrl.map(fetchResource));

  const allScript = [...script, ...fetchScripts];

  const data = { script: allScript, html };

  cache[app.name] = data;

  return data;
};

export const loadHtml = async (app: AppItem) => {
  const { script, html } = handleResource(app);
  // ...
  script.forEach((item) => {
    performScriptForEval(item, app);
  });
  // ...
  return app;
};

预加载子应用

结合上面的缓存子应用功能,我们可以对其他的子应用进行预加载,这样子在子应用切换的时候,也可以提高子应用的显示速度

代码如下:

export const prefetch = async () => {
  // 获取其余子应用
  const appPieces = getList().filter(
    (item) => !window.location.pathname.startsWith(item.activeRule)
  );

  // 加载所有子应用
  await Promise.all(appPieces.map(handleResource));
};
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值