分析一下微应用框架(一)

前言

对于大型前端应用的演进,微应用架构是一种尝试,微应用技术在端上本质就是一个组件资源的管理,组件可以是页面,也可以是一个弹框内容,甚至极端一点把一个逻辑很复杂的按钮作为一个应用来接入。在 2017 年 FCC 的时候分享过微应用的基础认识,当时技术直觉上觉得它碉堡了,还能这么玩前端页面。现在业务解决方案的落地形式很多,市场证明了微应用是能解决部分前端应用演进中问题的技术,这篇文章主要是回顾微应用在框架层面如何实现的,了解框架原理,才能更好更快的映射业务层接入的问题。

选对象

  • single-spa -> qiankun

  • icestark

  • micro-app

这 4 个框架都是微应用相关,qiankun 内部依赖了 single-spa,所以不能只学习一个来理解原理,需要逐渐闭环的学习,single-spaicestark 更加合适一些,这里选择 single-spaqiankun 结合来先理解基础微应用框架整体的实现。

目录

  • 基础设计

  • 资源管理

  • 应用隔离

基础设计

主要是这 3 部分,框架层面并不会解决很多接入问题,更多是应用组织的能力。

  • 应用注册
import { registerApplication } from "single-spa";

registerApplication({
  name: "应用名称",

  app: () => System.import("应用启动组件"),

  activeWhen: (location) => (location.hash = "#app1"),
});
  • 应用启动和加载
import { start } from 'single-spa';

start();
  • 应用生命周期
class App {
  load() {}

  bootstrap() {}

  mount() {}

  unmount() {}

  unload() {}
}

class App1 extends App {}

接下来我们依此来分析每一部分:

1. 应用注册实现过程
  • registerApplication

  • unregisterApplication

位于:src/applications/apps.js

// 主要是组织 app 的基础配置
// app 的配置使用的是一个全局的变量进行存储
const apps = [];

// 应用注册
function registerApplication(appConfig: IAppConfig) {
  const defaultAppConfig = {
    // 默认没有加载
    status: NOT_LOADED,
  };

  Object.assign({}, defaultConfig, appConfig);
  // 默认会根据路由信息,尝试启动一次应用
  // 根据是否调用了 start() 启动,如果启动了就会调用 reroute->loadApps

  if (isStarted()) {
    reroute();
  }
}

// 应用卸载

async function unregisterApplication(appName: string) {
  const app = app.find(appName);

  // 加入到去除应用缓存数据
  appToUnload.push(app);

  // 先卸载,然后才去除
  app.status = UNMOUNTING;
  await app.unmouted();
  app.status = NOT_MOUNTED;

  app.status = UNLOADING;
  await app.unload();
  app.status = NOT_LOADED;

  setTimeout(() => reroute());
}
2. 应用加载实现过程

加载过程主要是加载资源,然后渲染到对应的容器节点,可以理解为:

function loadApps() {
  apps.forEach((app) => {
    app.loadApp(app.props);
  });
}

loadApp 就是我们可以扩展的地方了,因为 single-spa 主要是应用层的管理,并没有实现具体每一个步骤的细节。

这里我们参考一下 qiankun 框架的 loader 模块,这个模块提供了具体 loadApp 的加载过程实现。

  • 首先需要读取 app HTML 文档信息,qiankun 采用三方 import-html-entry 进行解析,也可以使用 DOMParse 进行 demo 编写。
const {
  // html 部分代码
  template,
  // scripts
  execScripts,
  // 静态资源的 public-path 部分
  // https://webpack.js.org/guides/public-path/#on-the-fly
  assertsPublicPath,
} = importHtmlEnter(app.entry, importHtmlEntryOptions);
  • 解析出来后,先对 template 进行一个改写,加入 qiankun 的一些特殊处理,主要是添加 headwrapper-id
<div id={getWrapperId(appName)} data-name={appName} data-version=${version}>
  <qiankun-head>
    {tempalte}
  </qiankun-head>
</div>
  • HTML 结构调整后,就该处理脚本的执行

脚本的处理就没有 html 结构方便了,需要更多考虑执行的上下文带来的变量污染、访问权限隔离等问题,特别是有不可控三方资源的时候,莫名其妙的问题就会不那么可控。其实解决问题的思路有很多,有时候也会觉得微应用是不是也可以用产品的思维去降低应用复杂度,但是作为技术还是需要深入去解决一个技术类型问题。

  1. 首先我们需要利用 execScripts 这个 api 去处理脚本的执行。
execScripts(
  sandbox?: object,
  strictGlobal?: boolean,
  execScriptsHooks?: ExecScriptsHooks
): Promise<unknown>;

脚本的执行需要处理情况比较多:

  • inline-scripts 解析执行

  • async-scripts 会在 idle 阶段去加载

  • 脚本的 sandbox 隔离

所以我们需要先创建一个 SandboxContext 来作为 globalThis 对象,避免全局变量跨应用的污染问题。qiankun 提供了 3 种模式的脚本沙箱:LegacySandboxProxySandboxSnapshotSandbox

这里主要看下 ProxySandbox 隔离较完整的方案,LegacySandboxSnapshotSandbox 本质上还是访问的同一份 window 实例,然后通过代理对象的访问器或者一些缓存策略来实现变量操作快照。

// 伪代码
const proxy = new Proxy(fakeWindow, {
  set(target, prop, value) {
    if (!target.hasOwn(props) && globalContext.hasOwn(prop)) {
      // target 重新定义一个与 globalContext 描述符一致的 {prop, value}
    } else {
      // 如果都没有就直接产生一个属性值

      target[prop] = value;
    }

    // 处理一些白名单属性
  },

  get(target, prop) {
    const actualTarget = prop in target ? target : globalContext;

    const value = actualTarget[prop];

    return value;
  },
});

所以我们能够存在多子应用的多实例沙箱,借助的基本就是完全代理访问的实现,没有再直接对 globalContext 进行的操作。

脚本沙箱大致原理就是这样,接下来我们就调用 execScripts 去执行脚本:

// 需要设置开启沙箱才会采用代理对象
if (sandbox) {
  global = sandboxContainer.instance.proxy
}

await execScripts(global);
  1. 脚本解析完成后,接下来就是处理 css

qiankun 有 3 种处理 css 隔离或者说是权重变更策略:

  • 第一种:直接加载 css 到 dom,没有隔离

  • 第二种:使用 ScopedCSS 模式

  • 第三种:使用 ShadowDOM 的模式

第一种其实个人认为挺好,也是 icestark 首先建议的一个约定方案,就是依然采用添加命名空间和调整 CSS 权重来控制样式。这种模式好处就是自由且没有成本,不足就是其实没有方案,约定的成分很大,对大团队管理其实是一个考验。

第二种类似 Vue 的 scoped,主要会添加一个 data-qiankun=${appInstanceId} 的属性标识,通过对 style textContent 进行 rewrite CSSRule 的方式解析添加前缀。核心实现伪代码如下:

// 入口
const tag = (mountDOM.tagName || "").toLowerCase();
const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`;

if (tag && stylesheetElement.tagName === "STYLE") {
  processor.process(stylesheetElement, prefix);
}

// 关键需要支持 STYLE | SUPPORT | MEDIA
// 会进行一个 swapNode 的操作,重写 rules
const rules = arrayify<CSSRule>(sheet?.cssRules ?? []);

// cssText 的字符串替换就在 rewrite 逻辑
const css = this.rewrite(rules, prefix);

styleNode.textContent = css;

scoped 前后我们的样式会像下面这样:

// old
.main {}

// new
div[data-qiankun="appName"] .main {}

我们这个时候也可以看下 Vue 对 scoped 是怎么支持的。伪代码简单模拟下思路:

@depends -> @vue/component-compiler-utils 和 postcss-selector-parser

// 使用 postcss 的插件体系,针对 attribute 进行改写,其中还支持了很多与处理器和 >>> deep 的语法
postcss.plugin("add-id", (options) => (root) => {
  root.each(function rewriteSelector(node) {
    node.selector = selectorParser((selectors) => {
      selector.insertAfter(
        node,
        selectorParser.attribute({
          // 这里的 id 是有生产过程,这里不做介绍

          attribute: id,
        })
      );
    });
  });
});

其实思路都很类似,拿到 css 进行一个 rewrite attribute 的思路,只是工具不一样。区别主要是一个是在编译时,一个是在运行时。

第三种方案是一个标准提供的隔离方案,直接上代码吧,代码不多,这个方案伪代码如下:

let shadow;

if (appElement.attachShadow) {
  shadow = appElement.attachShadow({ mode: "open" });
} else {
  shadow = appElement.createShadowRoot();
}

shadow.innerHTML = clone(appElement.innerHTML);

实现效果我们用官方的 Demo 通过 debug 串改看下效果:

image.png

这种方式好处很明显:是一个标准提供的浏览器隔离作用域,此渲染的 DOMTree 是不与主 DOMTree 相互影响的,它的根节点是 ShadowRoot,不再还是一个文档下的隔离。不足的地方主要在于有了 host 域的概念,这样在 js 脚本中的对象访问作用域的 Root 就无法互通,主要坑点盲猜应该是三方的依赖。。。如果可以在全局做好重写访问链是可以解决的。

所以这个的场景更多来自于自闭环组件采用 loadMicroApp 方式直接加载,作为一个独立存在的组件资源。

Tips: 技术本身是没有好坏强弱的,主要看你用在哪~经过上面三种方式,更多实用的还是第一种和第二种,第一种如果是较新项目和闭环组织的团队比较合适,第二种更加中庸一些。

还可以思考一些全局浏览器占用的资源管理情况,比如:Storage、indexDB 等场景。

上面介绍更多是应用基础管理和加载部分,还有很多模块都是很值得专题研究的,比如:路由管理、加载应用的策略、应用在不同场景生命周期状态的变换等等。

相比下来,反而在微应用体系下,应用通信这块并不是特别难的点,这跟微应用技术定位和核心解决问题有关系。既然要拆应用,那么通信就是总线模式,只要有一个渠道来处理隔离应用类型的通信 API(这里泛指应用接口)就行,大多业务的场景是可以直接枚举,并且枚举数不会太多。

参考资料

© 版权声明
THE END
喜欢就支持一下吧
点赞10 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容