落地中的微前端调度器

132 阅读12分钟

背景: 公司5年发展背景,沉淀下来的内部服务有70多个中后台应用, 将近2000+页面,为了统一能够调度这些业务中页面,需要一个能够加载这些应用的调度器, 像iframe一样, 前期可以无缝接入(0成本加载过来), 不需要配合子应用一系列配置, 做到样式隔离, JS沙箱隔离策略。

实践中, 系统和应用概念隔离开, 系统对应着某一方面业务,他的特点就是动态的,某一刻可能需要营销侧团队支持, 某一刻可能还需要业务团队技术参与, 又在某一时刻, 该系统还要具备工具团队能力。所以系统应该可以跨团队提供服务, 因此系统需要一个可以跨团队, 跨应用的加载器。

现代微前端需要解决的问题

微前端本质的其实还是解决, 代码复用的问题, 只是层次更高,做到跨应用、跨服务、跨构建体系、跨框架体系React、Vue等,一种技术实现手段。

在跨应用体系过程中, 可以帮我解决如下问题:

1. 所有业务都集中到一个系统中的场景

巨石应用, 无论后期升级、维护、发布体系,都会给开发带来各种头痛问题

2. 分散的应用体系

如果所有业务全部分散在各个子系统中

3. 调度体系下微前端

系统可以聚合这些应用

现代微前端体系建参考

📎美团到综微前端实践及系统架构演进.pdf

📎刘飞-达达微前端.pdf

📎刘奎-蚂蚁微前端.pdf

📎夏温武—阿里飞冰微前端.pdf

开源生态

微前端任务编排

服务发现

  • import-maps
    • 浏览器接口
      • import-maps介绍
      • import-maps兼容性

import-html-entry 其实就是利用传统应用服务特点,帮我们把注册好的服务模块加载过来, 典型应用场景是qiankun

  • webpack联邦模块

模块联邦, 也算是微前端技术建设体系的一部分, 扮演着,页面、模块服务注册发现角色。

组合案例

single-spa与import-maps 组合

  1. 暴露import-maps 数据, 需要自己独立处理子模块发布体系建设问题。模块发布控制力度将会更细致。

  1. sytemjs需要通过system-maps 把模块加载进来, 并执行相应的接口。

  1. system加载的子模块需要暴露出接口

single-spa 与 html-entry 组合

  1. 嵌入低代码平台

  1. 子应用React

主渲染入口

暴露接口给外部给systemjs加载

webpck打包配置

  1. 低代码组件 利用single-spa 与 import-entry 进行应用加载

发现服务 加载服务脚本

运行脚本

运行脚本接口

qiankun 内置 html-entry与single-spa组合

satumjs 内置 html-entry组合

沙盒

应用级别加载一般情况加,都是跨构建体系, 跨框架体系,为了保证JS运行不受污染, CSS样式冲突问题,需要对复用模块,和应用进行沙盒处理。

JS SandBox

总结

所谓微前端, 应该是一个体系建设, 通过这些技术,来在自己体系里, 做到模块、页面、应用级别复用。模块页面、应用级别的通信机制,CSS、JS隔离策略、跨各种构建体系模块加载。还要根据自身业务特点,去做应用级别拆分、模块级别拆分, 发布体系建设等。

至于这些复用策略, 要不要去中心化, 个人觉得这是一个层面性问题,如果应用级别复用, 中心化应用可以做到跨应用间, 统一鉴权,统一导航条,统一菜单等, 中心化应用职责应该发现并载入服务,做到应用、页面、模块任务的调度、沙箱隔离等。至于模块层面, 每个应用可以暴露出接口, 供其他应用使用。像联邦模块这种技术使用,包括systemjs。

我的微前端调度器体系建设

公司内部存在中后台服务70多个, 2000+个页面, 目前每个应用对应一个服务, 营销、教研、运营完成某一方面业务,需要在70多个应用服务之间来回切换。 所以为了统一这些入口, 需要一个中心化应用, 来调度这些页面,最终可以拼凑成营销侧管理系统、教研侧管理系统、运营侧管理系统、并且这些管理系统导航,菜单、鉴权,都可以统一到中心化应用体系中来。 这样研发侧可以根据自己组织架构, 业务侧进行应用力度的拆分, 防止巨石应用。

  1. 教研中后台

基于SatumJS

1. 为什么基于SatumJS

SatumJS 其内部是基于 html-entry 进行应用服务加载, 其本质是借用传统应用服务站点,来完成整个微应用加载

SatumJs 不同于qiankun, 其基于微内核架构,自身有很强扩展性, 可以根据应用自身特性, 实现比如沙盒, 微应用资源加载的劫持处理,CSS代码处理,JS代码处理, 做到向iframe一样, 子应用不需要适配代码,可以接入的能力。

因为涉及70多个服务应用, 如果为了每个应用, 都能要加载到微前端框架中,去修改每个项目配置和代码, 其成本太高,有时还涉及项目权限问题,所以基于以上种种原因,我选择了SatumJS作为整个中心化应用的内核。

落地过程中遇到问题

落地过程遇到很多问题, CSS污染问题、弹框问题、中心化应用与子应用间的JS污染问题, 应用间全局变量污染问题, 为了解决上诉问题需要一个完备的沙箱机制。

  1. 配置代码
// 微应用挂载的节点
const container = '#micServiceNode';


// 接入公司高频12应用服务
const appsConfig = [
  /** 配课系统 */
  {
    name: 'tanyue-course-admin',
    entry: 'https://xxxxx.codemao.cn/',
    rules: {
      rule: '/tanyue-course-admin',
      container,
    },
  },
  /** 运营系统 */
  {
    name: 'tanyue-app-admin',
    entry: 'https://xxxx.codemao.cn',
    rules: {
      rule: '/tanyue-app-admin',
      container,
    },
  },
  
  /** 探月侧CRM  */
  {
    name: 'crm_web_moon',
    entry: 'https://xxxx.codemao.cn',
    rules: {
      rule: '/crm_web_moon',
      container,
    },
  },
  /** 小火箭CRM */
  {
    name: 'crm_web_rocket',
    entry: 'https://xxxx.codemao.cn',
    rules: {
      rule: '/crm_web_rocket',
      container,
    },
  },
  /** 录播课班主任工作台 */
  {
    name: 'codemaster_lbk_crm',
    entry: 'https://xxxx.codemao.cn',
    rules: {
      rule: '/codemaster_lbk_crm',
      container,
    },
  },
  /** 客户关系管理 */
  {
    name: 'crm',
    entry: 'https://xxxxx.codemao.cn',
    rules: {
      rule: '/crm',
      container,
    },
  },
  /** 信息化管理系统 */
  {
    name: 'information_manage_frontend',
    entry: 'https://xxxxx.codemao.cn',
    rules: {
      rule: '/information_manage_frontend',
      container,
    },
  },
  /** 其他管理系统 */
  {
    name: 'student_info',
    entry: 'https://xxxx.codemao.cn',
    rules: {
      rule: '/student_info',
      container,
    },
  },
  /** 小火箭运营系统 */
  {
    name: 'web_rocket_app_config',
    entry: 'https://xxxx.codemao.cn',
    rules: {
      rule: '/web_rocket_app_config',
      container,
    },
  },
  /** 教师端-新上课系统 */
  {
    name: 'lbk_operational_system',
    entry: 'https://xxxx.codemao.cn',
    rules: {
      rule: '/lbk_operational_system',
      container,
    },
  },
  /** 工具侧新上课系统2 */
  {
    name: 'mlz_teacher',
    entry: 'https://xxxx.codemao.cn',
    rules: {
      rule: '/mlz_teacher',
      container,
    },
  },
  /** 营销侧运营后台 */
  {
    name: 'marketing_admin_frontend',
    entry: 'https://xxxx.codemao.cn/',
    rules: {
      rule: '/marketing_admin_frontend',
      container,
    },
  },
];

\

  1. satumjs微内核实际使用代码
import { 
  register,
  start, 
  use, 
  MidwareName, 
  corsRuleLabel,
  setHostHistory
} from '@satumjs/core';
import singleSpaMidware from '@satumjs/midware-single-spa';
import {
  simpleSandboxMidware,
  imageUrlCompleteMidware 
} from '@satumjs/simple-midwares';

register(appsConfig);

// 自建的资源跨域转发服务  解决子应用服务没有跨域的问题
const corsRule =  `https://xxxx.codemao.cn/fetch/${corsRuleLabel}`
use((sys, _, next) => {
  sys.set(MidwareName.urlOption, { corsRule });
  next();
});
 
// 解决字体, 图片加载问题
use(imageUrlCompleteMidware, {
  getFinalUrl(url) {
    if (url.match(/.woff/)) {
      url = corsRule.replace(corsRuleLabel, encodeURIComponent(url))
    }

    return  url;
  }
});

// 内部模拟single-spa接口,在应用挂在点container上切换应用
use(singleSpaMidware);

// 简单沙盒实现, 实际场景中, 需要为该沙盒扩展能力,来解决以下问题
use(simpleSandboxMidware);

start();

1. 资源跨域问题

satumjs demo 通过thingproxy, 为了代理服务的稳定性,基于该开源库自行搭建该服务

// 自建的资源跨域转发服务  解决子应用服务没有跨域的问题
const corsRule =  `https://xxxx.codemao.cn/fetch/${corsRuleLabel}`
use((sys, _, next) => {
  sys.set(MidwareName.urlOption, { corsRule });
  next();
});

2. 资源加载相对路径问题

无论是图片、css中资源路径、xhr路径问题, 都需要解决绝对路径和相对路径资源加载的问题, 因为中心化应用跟子应用不在一个服务上

1. 图片资源、字体资源 路径问题

图片资源与css中字体图片路径问题, 内部通过dom变化检索到图片标签进行纠正, 通过css代码劫持,匹配css rule中url value 解决字体、背景图片的问题。

// 解决字体, 图片加载问题
use(imageUrlCompleteMidware, {
  getFinalUrl(url) {
    if (url.match(/.woff/)) {
      url = corsRule.replace(corsRuleLabel, encodeURIComponent(url))
    }

    return  url;
  }
});
2. webpack publicPth 路径问题

webpack 配置有一个pubicPath 配置项, 就是用来指定打包后资源放入那个服务及路径,常规做法指向cdn存储资源的地址, 如果该配置项被配置绝对路径和相对路径,则代表着,该资源在具体部署服务器上,同该页面html文件在一个服务上。

对常规脚本加载, 比如: html中entry 都在内核中自行处理掉了。 这个case 常发生在webpack 异步脚本中 webpack_require.p

解决方案, 重写document.createElement方法, 劫持路径, 劫持资源加载过程

3. XHR 请求路径问题

同样XHR 请求中也会带有路径问题

解决方法,在sandbox 劫持XHR, 修复请求路径

3. 处理entry-html中内联Script标签中全局变量, 避免污染全局环境, 放入沙盒中

子应用html代码出现如下 var 声明, 该变量可能会溢出 with 属性环境,成为全局变量声明

解决方案则在with 的proxy.has 方法中解决, 通过一个iframe干净contentWindow,来判断该声明是否封装在该sandbox中。避免全局污染。

4. 处理entry-html中Script标签中UMD模块,防止UMD模块挂载到全局环境,放入沙盒中

在UMD 头部, 函数执行入口this 默认将会指向全局, 如下:

为了防止该属性指向global, 需要为该UMD代码进行一次包装处理

这样UMD模块在该包裹代码中执行, this指向沙盒, 解决UMD模块向全局环境,注入变量问题。

5. 处理Eval 环境链问题

  1. 先看一段Eval 执行相关case
// 如果eval 作为方法调用, 内部解释为引用调用, eval 作用域链将直接指向window
window.a = 'global'
(function () {
  var a = 'scoped';
 
  (0, eval)('console.log(a)');  // global
  eval('console.log(a)');  // scoped
  window.eval('console.log(a)'); // global
})();

// 1. 环境链 或 作用域链
  1. 该eval 执行问题发生在simpleSandboxMidware 中, 已被midware-proxy-sandbox修复

可见eval 已被simpleSandboxMidware 进行bind处理, 所以作用链丢失, eval 内部作用域链查找失败,出现ReferenceError

  1. 该问题被发现在webpack-dev-serve 构建产物中

  1. 解决方案

就是不对eval 进行特殊处理。

6. cdn Moment 库被bind 静态属性丢失问题

  1. 该问题也是发生在simpleSandboxMidware, 所有函数都被bind处理,向moment自定义一些函数方法静态属性访问失效。

  1. moment会在在一些版本信息, 静态方法

  1. 后续被bind 所有属性访问失效
  2. 解决方案 来自非标准函数, 都将重新做一层代理,包含静态属性代理

  1. 对非标准函数的代理, 做到单值表达式函数调用, this 指向沙盒。

  1. 该问题已经在midware-proxy-sandbox修复

7. 子应用路由匹配问题

每个子应用都独立匹配路径, 目前已Jenkins部署服务名, 为前缀匹配子应用

劫持处理history API

通过midware-proxy-sandbox winVariable 沙盒属性劫持

通过AppName 获取应用上下文

通过App上下文信息改写history Api 中path部分

劫持location API

midware-proxy-sandbox locationVariable中

根据APP上下文 ,重写location pathname和hash

const routerMatch = new Set<string | number | symbol>(["pathname", "hash"]);

上诉问题也已经在midware-proxy-sandbox得到有效解决

8. 中心化应用UI与子应用UI版本冲突问题

中心化应用Antd 与子应用Antd 版本存在冲突问题, 为了避免相互样式冲突, 最容易想到解决方案就是子应用渲染放入到shadowDom完成

这部分能力集成到 midware-proxy-sandbox中

export default function proxySandboxMidware(system: MidwareSystem, microApps: IMicroApp[], next: NextFn) {
  ProxySandbox.microApps = microApps;
  const {useSandBox = true} = ProxySandbox.options = system.options as ProxySandboxOptions;
  system.set(MidwareName.Sandbox, ProxySandbox);

  if(useSandBox) {
    system.use(fontFaceMidware);
    system.use(shadowRootMidware);
  }

  next();
}

整个子应用渲染放入到shadowDom中

9. 弹框问题

解决思路, 弹框内容也被封装在shadomDom, 作为shadomDom 一部分, 这样弹框样式丢失问题就解决了, 核心思路劫持document.body到shadowDom中。

该方案不具备完备性,在测试UI框架中,antd系列可用, 可能存会存在个别UI 弹框定位不准确问题, 大家可以提issues来解决。

  1. 劫持document
  2. document 劫持body

  1. 返回shadowDom中body节点

10. shadowDom中React事件代理问题

该问题由于React 采用了时间代理, 而shadowDom节点所有时间target都指向了 showdowDom 父层元素, 所以解决方案, 就是劫持addEventListener, 重新计算event.target

React源码
  1. 先看React事件代理过程

其实每个dom节点都挂在fiber 实例,

这样方便查找fiber树的上下文, 在代理事件中触发 冒泡方式、或捕获顺序触发每个fiber onClick方法

当我们在该节点试图删除该相关属性,点击将无法响应。

  1. 事件代理过程

可以看出代理到React.render 的根节点上

  1. 触发过程

本截取可能不太全面, 大家大概知道过程就好, 关键点要知道我点击在那个dom界面, dom界面有对应fiberNode, 根据fiberNode链条查找 触发onclick事件。

解决代码

在midware-proxy-sandbox 做了事件劫持

对MouseEvent.target进行了重新计算

至此该问题已得到完备的解决, 在React中

11. window document documentElement 事件清理

其实子应用还是通过向window document documentElement 进行事件注册, 这部分事件又不能进行完备进行代理, 所以需要手机这些被子应用注册过的时间, 当子应用间切换时, 及时清理

代理清理API

统一清理

沙盒销毁清理

目前该事件代理过程比较简单, 需要优化, 但能覆盖大部分使用场景

13. Vue 数据响应问题

发现 全局函数经过代理处理后, Vue 数据变化后, render 无响应了

Vue源码

Dep.target 指向Wacher 实例

getter 阶段 Dep 实例收集 Dep.target 中Watcher 实例

setter 时候 dep.notify dep.subs 放入是Watcher 实例

调用Watcher 实例的update 通知相关VM实例进行Render

解决方案

由于simpleSandboxMidware 沙盒内函数经过一层bind isNative 判断,所以通过Proxy代理后解决

toString 方法

14. 字体文件处理

此case 是发现 css @font-face Rule 在shadom-dom中不起作用, 也就是shadow-dom 声明字体文件无效

robdodson.me/posts/at-fo…

解决方案

内置到proxySandboxMidware 沙盒中

方法也很简单, 劫持每一个css代码, 匹配font-face rule 最后放入shadow-dom外部加载处理就好了。

总结

实际运用会遇到各种问题, 所以一个完备沙箱需要处理, 沙箱内各式各样的代码问题,有框架层面, 也有用户代码层面的, 所以一个完备沙箱需要各种各样case 来取覆盖 ,目前实现的沙箱, 只覆盖我们公司一些老旧项目应用场景, 如果在其他场景遇到问题, 欢迎issues。

致谢SatumJS作者提供一个这么好, 可扩展性超强微前端内核方案。

Satumjs 贡献

midware-proxy-sandbox

midware-proxy-sandbox fork