微前端实现原理研究总结

Posted 在厕所喝茶

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了微前端实现原理研究总结相关的知识,希望对你有一定的参考价值。

微前端实现原理研究总结

前言

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

微前端实现方案

  • 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 以上是关于微前端实现原理研究总结的主要内容,如果未能解决你的问题,请参考以下文章

RESTEASY ,从学会使用到了解原理。

从零开始写一个微前端框架-沙箱篇

从零开始写一个微前端框架-沙箱篇

解密微信域名检测API接口实现原理

猴子数据解密微信域名检测API接口实现原理

qiankun 微前端应用实践与部署(四)