微前端实现原理研究总结(代码片段)

在厕所喝茶 在厕所喝茶     2022-11-29     731

关键词:

微前端实现原理研究总结

前言

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

微前端实现方案

  • 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 从零开始写一个微前端框架-沙箱篇(代码片段)

前言自从微前端框架micro-app开源后,很多小伙伴都非常感兴趣,问我是如何实现的,但这并不是几句话可以说明白的。为了讲清楚其中的原理,我会从零开始实现一个简易的微前端框架,它的核心功能包括:渲染、JS沙箱、样式... 查看详情

前端知识点总结(javascript篇)(代码片段)

...JavaScript字符串转化JSONP原理及优缺点XMLHttpRequest事件委托前端模块化(AMD和CommonJS的原理及异同,seajs和requirejs的异同和用法)sessionCookieseaJS的用法及原理,依赖加载的原理、初始化、实现等this问题模块化原理(作用域)JavaScript... 查看详情

限流--sentinel相关实现原理学习总结(代码片段)

简介Sentinel是什么?随着微服务的流行,服务和服务之间的稳定性变得越来越重要。Sentinel以流量为切入点,从流量控制、熔断降级、系统负载保护等多个维度保护服务的稳定性。官方地址:https://github.com/alibaba/Sen... 查看详情

三.[前端总结]之浏览器篇(代码片段)

1. 跨标签?通讯 不同标签?间的通讯,本质原理就是去运??些可以共享的中间介质,因此?较常?的有以下?法:通过???window.open()和???postMessage  异步下,通过window.open(‘about:blank‘)和tab.location.href=‘*‘设置同域下共享的localSt... 查看详情

前端面试总结(代码片段)

第一家(360)vue双向绑定的原理es6var和let的区别前端数据可视化栅格化vueX的原理,状态管理器,怎么存值,怎么取值,怎么改变值布局第二家(***)第三家(好未来)iframe跨域第四家(伶仃英语) 查看详情

我对react实现原理的理解(代码片段)

React是前端开发每天都用的前端框架,自然要深入掌握它的原理。我用React也挺久了,这篇文章就来总结一下我对react原理的理解。react和vue都是基于vdom的前端框架,我们先聊下vdom:vdom为什么react和vue都要基于vdom... 查看详情

前端智能化实践——可微编程(代码片段)

...什么是可微编程通过动画、动效增加UI表现力,作为前端或多或少都做过。这里以弹性阻尼动画的函数为例:函数在时是效果最好的。最终,实现成Jav 查看详情

前端错误监控总结(代码片段)

【要点】1.前端错误的分类2.错误的捕获方式3.上报错误的基本原理 【总结】1.错误分类运行时错误,代码错误资源加载错误 2.捕获方式代码错误捕获  try...catch:tryconsole.log("欢迎光临!");catch(err)document.getElementById("xxx").in... 查看详情

学习笔记springcloud微服务架构(代码片段)

...1.负载均衡原理1.1负载均衡流程图2.负载均衡策略2.1IRule的实现图2.2内置负载均衡规则类2.3调整负载均衡的规则3.饥饿加载4.总结五、nacos注册中心1.认识和安装Nacos2.Nacos快速入门2.1服务注册到Nacos2.2总结3.Nacos服务分级存储模型3.1Naco... 查看详情

springboot:实现jpa+thymeleaf用户管理(代码片段)

...原理二、代码1.基本类(po/dao层)2.服务层3.控制层4.thymeleaf前端代码5.演示效果总结今天布置的springboot小作业:实现controller和模板/users查看用户列表/users/del删除用户/users/add新增用户/users/edit修改用户一、实现原理前端采用semanticui框... 查看详情

springboot:实现jpa+thymeleaf用户管理(代码片段)

...原理二、代码1.基本类(po/dao层)2.服务层3.控制层4.thymeleaf前端代码5.演示效果总结今天布置的springboot小作业:实现controller和模板/users查看用户列表/users/del删除用户/users/add新增用户/users/edit修改用户一、实现原理前端采用semanticui框... 查看详情

springboot:实现jpa+thymeleaf用户管理(代码片段)

...原理二、代码1.基本类(po/dao层)2.服务层3.控制层4.thymeleaf前端代码5.演示效果总结今天布置的springboot小作业:实现controller和模板/users查看用户列表/users/del删除用户/users/add新增用户/users/edit修改用户一、实现原理前端采用semanticui框... 查看详情

窥探原理:实现一个简单的前端代码打包器roid(代码片段)

...本文,你可以实现一个非常简单的,但是又有实际用途的前端代码打包工具。如果不想看教程,直接看代码的(全部注释):点击地址为什么要写roid?我们每天都面对前端的这几款编译工具,但是在大量交谈中我得知,并不是很... 查看详情

dubbo原理应用与面经总结(代码片段)

...icrokernel只负责组装Plugin,Dubbo自身的功能也是通过扩展点实现的,也就是Dubbo的所有功能点都可被用户自定义扩展所替换。对于第一点比较容易理解,因为是分布式环境,各系统之间的参数传递基于URL来携带配置信息,所有的参... 查看详情

exp10final微信防撤回原理与实现(代码片段)

一、写在前面1.为什么做免考?相较于考试,免考更能锻炼自身创新和探索能力,更有挑战性。2.选做微信推送防撤回原因?微信已经成为日常沟通中必不可少的工具,如何知道别人在你没看微信的情况下偷偷说了什么就变得很... 查看详情

python+opencv实现人脸微整形(代码片段)

目录一、前言二、主要原理三、算法实现(1)计算偏移量(2)考虑多个点影响(3)控制点的手动增加,删除功能四、总结一、前言表情捕捉驱动另一张脸或者3D人脸是元宇宙一项比较热门的技术,... 查看详情

原来aqs实现原理还能如此总结(代码片段)

...的同步器,它提供了一个FIFO队列,可以看成是一个用来实现同步锁以及其他涉及到同步功能的核心组件。02AQS的核心思想如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共 查看详情

前端javascript设计模式前奏--面向对象jq实例与总结(代码片段)

1.面向对象–JS的应用举例/***1.我们可以认为JQuery就是一个类*1.JQ的打包源码中是一个函数,这个函数就是一个构造函数,其实就是一个class。*2.$('p')其实就是JQ的一个实例。**2.实现原理(实际上使用的ES5的构造函数):*/classjQueryco... 查看详情