微前端是什么
微前端是一种技术手段和策略,把一个单一的web应用拆分成独立的微应用,每个微应用可以单独开发、部署和运行。
微前端的应用场景
- 合并多个系统 由于历史原因,我们可能会有多个技术栈不同的项目(vue, react, angular, jquery)。当我们需要把这些系统都整合到一个平台上使用的时候,就可以考虑微前端方案。
- 渐进式重构旧项目 随着业务的迭代,有些系统会成为巨石应用,构建部署慢,新模块的开发成本过高,迫切需要一个新项目来开发新的功能。但是旧项目不可能推倒重构,很多旧模块还需要继续维护,这时可以新建一个基座项目来集成新旧项目。
微前端框架调研
single-spa
single-spa算是微前端框架的先驱,现在已经更新到6.x版本。基本上所有介绍微前端框架的文章都会提到single-spa,然后说它哪里不好,哪里不完美。但是“没有调查就没有发言权”,所以接下来我们就来一步一步接入single-spa,看看它是否真的这么差。
single-spa的基本流程:1.注册应用。2.启动应用
首先打开single-spa的官方文档,我直奔文档的Example模块,希望能看到几个简单的示例快速接入,结果花了10分钟我还是没看到一个能快速看懂怎么用的示例。最终只能硬着头皮从头开始看文档。
结合文档和示例可以大概理解single-spa是如何使用的。首先看一段主应用的示例代码:
registerApplication({
name: 'app1', // 子应用名称
app: () => import('src/app1/main.js'), // 加载子应用的入口文件
activeWhen: (location) => location.pathname.startsWith('/app1'), // 何时渲染子应用
customProps: {
some: 'value', // 一些自定义参数可以传给子应用
}
});
start(); // 启动
简单来说,就是子应用需要在入口文件导出bootstrap, mount, unmount三个函数(返回Promise),主应用会在合适的时机去调用它们,完成子应用的渲染和卸载,所以子应用渲染的逻辑应该写在 mount中。
react应用接入
具体到我们使用的react子应用,改造后的index.js长这样:
import React from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import About from '@/pages/about';
import Home from '@/pages/home';
const router = createBrowserRouter([
{
path: '/',
element: <Home/>,
},
{
path: '/about',
element: <About/>,
},
])
let root = null;
export async function bootstrap(props) {
const {
name, // The name of the application
singleSpa, // The singleSpa instance
mountParcel, // Function for manually mounting
customProps, // Additional custom information
} = props; // Props are given to every lifecycle
}
export async function mount(props) {
const container = document.getElementById('microapp-container');
root = createRoot(container);
root.render(
<App />
)
}
export async function unmount(props) {
root && root.unmount();
}
普通应用我们会在入口文件直接执行createRoot(container).render(<App/>),现在需要把这个逻辑放到mount函数中并导出。通常,我们用来挂载react应用的根节点id都喜欢命名为root或者app,但是主应用会运行子应用入口文件的js,并运行mount方法去找到对应的节点,把子应用渲染出来。所以为了避免冲突,主应用中要挂载子应用的节点id我们命名‘microapp-container’,子应用也要把使用的根节点改为‘microapp-container’。
熟悉webpack的同学已经知道子应用还需要打包输出为UMD格式的js,否则主应用无法拿到它导出的方法。不了解的同学可以简单看看,熟悉的可以直接跳过:
子应用为什么需要输出UMD包
新建一个简单的js文件:
// webpack.config.js const path = require('path'); module.exports = { entry: path.resolve(__dirname, 'index.js'), output: { filename: 'main.js', path: path.resolve(__dirname, 'dist'), }, mode: 'development', devtool: false, }用webpack来打包这个js:
// webpack.config.js const path = require('path'); module.exports = { entry: path.resolve(__dirname, 'index.js'), output: { filename: 'main.js', path: path.resolve(__dirname, 'dist'), }, mode: 'development', devtool: false, }查看打包输出的main.js(这里为了容易看简单修改了一下):
(() => { "use strict"; var __webpack_require__ = { d: (exports, definition) => { for (var key in definition) { if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); } } }, o: (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)), r: (exports) => { if (typeof Symbol !== 'undefined' && Symbol.toStringTag) { Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); } Object.defineProperty(exports, '__esModule', { value: true }); } }; var __webpack_exports__ = {}; __webpack_require__.r(__webpack_exports__); __webpack_require__.d(__webpack_exports__, { bootstrap: () => (bootstrap), mount: () => (mount), unmount: () => (unmount) }); async function bootstrap(props) { console.log('bootstrap') } async function mount(props) { console.log('mount') } async function unmount(props) { console.log('unmount') } })();可以看到webpack在一个自执行函数中实现了一套模块机制,把我们导出的函数挂载到名为
__webpack_exports__的变量上。而在自执行函数外面,我们是拿不到__webpack_exports__的。如果把输出格式改成UMD,再看一下输出结果:(function webpackUniversalModuleDefinition(root, factory) { if (typeof exports === 'object' && typeof module === 'object') module.exports = factory(); else if (typeof define === 'function' && define.amd) define([], factory); else if (typeof exports === 'object') exports["main"] = factory(); else root["main"] = factory(); })(self, () => { return (() => { "use strict"; var __webpack_require__ = {}; // 省略__webpack_require__的实现 var __webpack_exports__ = {}; __webpack_require__.r(__webpack_exports__); __webpack_require__.d(__webpack_exports__, { bootstrap: () => (bootstrap), mount: () => (mount), unmount: () => (unmount) }); async function bootstrap(props) { console.log('bootstrap') } async function mount(props) { console.log('mount') } async function unmount(props) { console.log('unmount') } return __webpack_exports__; })(); });这段代码根据不同的运行环境把
__webpack_exports__导出,这样外部代码就可以拿到我们导出的3个方法了。
不过在single-spa的官网文档上没有找到关于子应用输出格式的说明,作者可能认为大家都懂,而且示例中还直接用了import函数来加载子应用的main.js。在使用webpack作为构建工具的项目中,import语句会被webpack处理,所以无法像示例一样使用。根据import函数的用法,我们可以自己实现一个加载函数:
registerApplication({
name: 'reactapp',
// app: () => import('http://localhost:9001/reactMain.js'),
app: () => {
return new Promise(async (resolve, reject) => {
const script = await fetch('http://localhost:9001/reactMain.js').then(res => res.text());
const module = new Function('exports', script + '\nreturn exports;')({});
resolve(module.reactMain);
})
},
activeWhen: (location) => location.pathname.startsWith('/reactapp'),
customProps: {
some: 'value',
}
})
这样写最终会把子应用导出的三个函数挂载在一个对象上,然后返回给app函数。
由于主应用和react子应用都用了history路由,所有子应用的路由都需要加上前缀/reactapp,否则子应用如果导航到首页/,主应用将无法激活任何一个子应用。
const router = createBrowserRouter([
{
path: '/',
element: <Home/>,
},
{
path: '/about',
element: <About/>,
},
], {
basename: '/reactapp', // createBrowserRouter的第二个参数可以配置路由basename
})
一通操作下来,子应用reactapp已经可以正常渲染并切换路由了,但是当浏览器url为http://localhost:9000/reactapp/about时,我们刷新页面,希望reactapp加载出来并处于about页面。结果发现父应用无法正常加载,父应用本来应该加载http://localhost:9000/main.78511c00.js,但是却加载了http://localhost:9000/reactapp/main.78511c00.js。这是因为webpack默认的output.publicPath是/,我们需要手动设置这个路径,在开发环境下设置为http://localhost:9000/。
vue应用接入
首先也是入口文件导出三个方法:
import { createApp } from 'vue'
import App from './App.vue'
import router from './router';
let app = null;
export function bootstrap() {
return Promise.resolve().then(() => {
console.log('bootstrap')
})
}
export function mount() {
return Promise.resolve().then(() => {
app = createApp(App);
app.use(router)
app.mount('#microapp-container')
})
}
export function unmount() {
return Promise.resolve().then(() => {
const el = document.getElementById('vue-app-style-tag');
if(el) {
document.head.removeChild(el);
}
app && app.unmount();
})
}
然后修改打包配置,输出umd包:
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true,
devServer: {
port: 9002,
headers: {
'Access-Control-Allow-Origin': '*',
}
},
configureWebpack: {
output: {
library: `vue-app-[name]`,
libraryTarget: 'umd',
chunkLoadingGlobal: `webpackJsonp_vue-app`,
},
}
})
然后启动开发服务。
在主应用注册vueapp:
registerApplication({
name: 'vueapp',
app: () => {
return new Promise(async (resolve, reject) => {
const vendor = await fetch('http://localhost:9002/js/chunk-vendors.js').then(res => res.text());
(new Function(vendor))();
const script = await fetch('http://localhost:9002/app.js').then(res => res.text());
const module = new Function('exports', script + '\nreturn exports;')({});
resolve(module['vue-app']);
})
},
activeWhen: (location) => location.pathname.startsWith('/vueapp'),
customProps: {
some: 'value',
}
})
需要注意的是,vue-cli创建的项目,打包的时候默认会把css抽离出来,所以生产环境中加载函数需要增加对css的引入:
const link = document.createElement('link');
link.setAttribute('rel', 'stylesheet');
link.setAttribute('type', 'text/css');
link.setAttribute('id', 'vue-app-style-tag');
link.setAttribute('href','http://localhost:9002/vue-app.css');
document.head.appendChild(link);
普通html接入
普通html项目的接入这里就不打算实现了,核心思路其实差不多,编写一个js实现bootstrap,mount和unmount函数。mount时把页面html内容渲染到microapp-container节点下,然后加载页面上的js,css等静态资源。unmount的时候要做相应的清理工作。
实现思路是显而易见的,但是具体做起来却会有非常多的细节需要处理。single-spa这个框架只对纯js渲染的应用比较友好,这一点从用法就可以看出来,它的文档甚至还建议开发者把子应用所有的资源加载逻辑都放到一个js里面。所以对于这类项目如果有接入微前端主应用中的需求,建议还是直接跳过single-spa。
总结
- 很显然,single-spa没有js沙箱,我们加载了一个远端js,然后在全局环境中直接执行了,各个应用之间的全局变量是有可能互相污染的。
- single-spa子应用之间的css也没有隔离,不同子应用和主应用的样式也可能会互相污染。
- 主应用需要实现子应用的入口文件加载逻辑,主应用的维护者必须很了解每一个子应用需要加载哪些资源。而且通常情况下,前端项目打包输出的静态资源都会带上哈希值,这意味着每一次子应用发布更新了,主应用的加载逻辑都要做出对应的修改。
前面使用的子应用,已经是简单到不能再简单的demo了,接入尚且如此费劲,更不用说实际使用的线上系统了,所以我认为在大部分的业务场景中,single-spa都不适用。
qiankun
qiankun是基于single-spa实现的,提供了更完善的功能,更易用的接口。
和single-spa类似,qiankun也是子应用导出mount和unmount方法,主应用注册子应用后启动。关于qiankun怎么用,在官网文档上写得非常清楚详细,基本是手把手教学,这里就不再赘述。
html entry
single-spa是js entry,而qiankun是html entry,注册子应用时只需要提供页面所在的位置即可,qiankun会去请求并分析页面内容,自动完成页面资源的加载。如下所示,注册子应用的逻辑非常简单:
registerMicroApps([
{
name: 'reactApp',
entry: '//localhost:9001',
container: '#microapp-container',
activeRule: '/reactapp',
},
{
name: 'vueApp',
entry: '//localhost:9002',
container: '#microapp-container',
activeRule: '/vueapp',
},
]);
相比single-spa,qiankun把资源加载的逻辑从应用转移到框架内部实现,真正做到了主应用和子应用的解耦,使得开发者使用起来心智负担很小。而且由框架来控制js的执行,为js沙箱的实现提供了可能。
webpack_public_path
qiankun为子应用提供了很多全局变量,如window.__POWERED_BY_QIANKUN__,用来判断当前应用是否在qiankun环境中运行。window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__,用来修改异步记载资源的public path。这些全局变量使得子应用既可以作为微前端体系中的一部分,又可以独立运行。虽然从技术实现上来看这不难,甚至很难说是一个闪光点,但是经历了single-spa之后你会发现,qiankun对于开发者确实更为友好。
webpack配置中的output.publicPath大家应该都很熟悉,这个值会作为所有资源url的前缀,它是在打包生成代码的时候就确定了的。在最终的打包产物中,html页面中加载的资源前缀会被设置为这个值,它无法被修改。假如我们设置了output.publicPath为https://cdn.xxx.com/,那么在html中可能会见到:
<script src='https://cdn.xxx.com/static/js/app.js'></script>
除此之外,应用中还会有许多异步加载的资源,这部分内容是通过js来加载的。按照output.publicPath,资源前缀会是https://cdn.xxx.com/,在打包产物中,它会被设置为__webpack_require__.p的值。但是这个值是可以在运行时动态修改的,这是webpack提供的能力。我们可以在应用入口文件的第一行这样设置:
__webpack_public_path__ = 'https://cdn.xxx.com/';
__webpack_public_path__ = 'https://cdn.xxx.com/'最终会被webpack编译成__webpack_require__.p = 'https://cdn.xxx.com/',这个赋值会覆盖掉output.publicPath的设置。
在qiankun的文档中,它是直接建议我们在项目入口给__webpack_public_path__赋值:
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__其实就是我们注册子应用时提供的entry。还记得前面使用single-spa加载react应用,由于主应用和子应用都使用history router,导致父应用的资源没有正确加载的问题吗,当时我们的处理方式是修改父应用的output.publicPath。qiankun提供的示例要解决的问题其实差不多,只不过是解决的是子应用异步资源加载的问题。
qiankun提供这样的示例,是假定我们页面所有静态资源都部署在同一个位置。一般来说,我们的前端资源要么是和html部署在一起,要么是全部部署在cdn节点上。其实无论是哪种方式,通过修改webpack配置的output.publicPath都是更好的选择。
js沙箱
在微前端的场景中,js沙箱主要用来执行子应用的js脚本,限制它能访问和修改的全局变量,使子应用之间的全局变量不会互相污染。
众所周知,qiankun有三种沙箱:SnapshotSandbox(基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器),LegacySandbox(基于 Proxy 实现的沙箱,单实例沙箱),ProxySandbox(基于 Proxy 实现的沙箱,支持多实例)。
每当一个SnapshotSandbox激活时,会用临时变量记录下当前window的快照,即window的所有属性值。当沙箱失活时,记录window属性的变更,然后根据快照恢复window对象。下一次激活时再根据“变更快照”来恢复当前沙箱对window的修改。它失活的时候要判断哪些属性发生了变化,需要遍历所有属性进行比较,所以说这是基于diff的沙箱。
LegacySandbox其实也是一种快照沙箱,只不过借助Proxy精确地记录下改变的window属性,在进行恢复和保存快照的时候,就不需要遍历所有属性了。
ProxySandbox为每一个沙箱实例创建了一个window对象,各个沙箱只修改自己的fakeWindow,所以ProxySandbox允许同时存在多个沙箱实例。
css隔离
qiankun提供了两种样式隔离的方案:基于 ShadowDOM 的严格样式隔离和修改子应用css选择器的样式隔离。这两种方案都没有那么完美,实际使用的时候需要根据实际情况做一些适配修改。幸运的是在大多数业务场景下,我们都不需要同时渲染多个子应用,所以样式隔离的问题不算特别严重。关于样式隔离的坑和解决方案的讨论可以参考:juejin.cn/post/718441…
micro-app
micro-app是京东前端团队推出的微前端框架,根据官网的介绍,它是借鉴了WebComponent的思想,模拟实现了ShadowDom的隔离特性,并结合CustomElement封装类WebComponent组件。
在官方文档的中有手把手章节,对于各种类型的项目如何接入做了很详细的指引,这里也不准备详细介绍框架怎么使用,简单的示例如下:
基座加载子应用很简单,像使用iframe一样, 初始化:
import microApp from '@micro-zoe/micro-app';
microApp.start(); // 显然这一步会声明自定义标签micro-app
加载子应用:
<micro-app name='react-app' url='http://localhost:9001/'></micro-app>
基座应用只需要配合路由库,在指定的位置渲染出不同的micro-app即可(如vue-router的router-view,react-router6的Outlet组件)。
micro-app加载子应用非常简单,所以这次我接入了静态的html页面,用来模拟多页面的项目。但是从index.html通过a标签跳转到profile.html时,由于是相对路径跳转,浏览器url变成了http://localhost:9000/profile.html。要实现正常加载,需要对页面跳转都做改造,修改成本会比较高。不过这并不是micro-app特有的问题,像singlea-app和qiankun也是一样的。对于这类项目,似乎只有iframe是最合适的。
基本原理
自定义标签
利用window.customElements.define实现自定义micro-app。MDN上有一个关于自定义标签的简单示例:
// Create a class for the element
class MyCustomElement extends HTMLElement {
static observedAttributes = ["color", "size"];
constructor() {
// Always call super first in constructor
super();
}
connectedCallback() {
console.log("Custom element added to page.");
}
disconnectedCallback() {
console.log("Custom element removed from page.");
}
adoptedCallback() {
console.log("Custom element moved to new page.");
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`Attribute ${name} has changed.`);
}
}
customElements.define("my-custom-element", MyCustomElement);
显然micro-app的关键逻辑在connectedCallback和attributeChangedCallback两个钩子函数中,在这里可以获取url属性和执行对应的加载和渲染逻辑。
和qiankun一样,micro-app也实现了一套html的加载和解析逻辑,把子应用的html内容渲染到micro-app标签下,在js沙箱中执行子应用的js。micro-app的js沙箱有点类似qiankun的LegacySandbox,都是利用Proxy来拦截对全局变量的读写操作。
元素隔离
元素隔离的概念来自ShadowDom,即ShadowDom中的元素可以和外部的元素重复但不会冲突,micro-app模拟实现了类似ShadowDom的功能,元素不会逃离
<micro-app>元素边界,子应用只能对自身的元素进行增、删、改、查的操作。
实现元素隔离是通过重写getElementById,querySelector等dom操作相关的api来实现的,确保子应用获取和修改的dom都是自身的,不会修改到父应用。
样式隔离
micro-app样式隔离有两种,一种是基于shadowDOM的,完全的样式隔离。另一种是基于css选择器作用域,为子应用所有css选择器加上前缀。例如:
.test { color: red; }
/* 转换为 */
micro-app[name=xxx] .test { color: red; }
无界
无界是腾讯开源的微前端框架,为vue和react项目提供了wujie-react和wujie-vue两个组件,所以用起来和micro-app很像。在已经接入过micro-app的主应用中,把micro-app标签换成对应的wujie组件就可以了。
接入之后首次加载子应用都是正常的,但是react子应用只有第一次可以正常渲染出来,切到其他路由之后再切回来,react子应用就渲染不出来了。通过日志打印可以看到react子应用中的window.__WUJIE_MOUNT和window.__WUJIE_UNMOUNT方法都有调用,但是子应用就是只有一个div标签。因为无界的用法和micro-app几乎一模一样,micro-app可以正常工作,无界不能,所以我会认为这是无界的一个bug。我不想花太多时间去深究原因,也就没有解决这个问题。
抛开使用过程中的问题不谈,无界的实现思路还是有点意思的。
核心原理
基于 WebComponent 容器 + iframe 沙箱
无界先把子应用的html内容渲染到Shadow DOM中,然后构造一个iframe来执行子应用的js代码。
在主应用中拿到字符串形式的子应用代码,要把代码注入到iframe中运行,所以这个iframe必须和主应用同域,至于子应用实际部署在什么地方无所谓,只要资源支持跨域请求就可以了。
如果我们想给一个同源iframe注入js代码,那么我们可以这么做:
const iframe = document.createElement('iframe');
// 监听iframe加载完成事件
iframe.onload = function () {
// 获取iframe文档对象
const iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
// 创建script标签
const scriptElement = iframeDocument.createElement('script');
scriptElement.type = 'text/javascript';
// 设置想要注入的JS代码
const code = `
const modal = document.createElement('div');
modal.innerText = 'This is a modal';
document.body.appendChild(modal);
`;
try {
scriptElement.textContent = code;
} catch (e) {
scriptElement.text = code;
}
// 将script标签添加到iframe文档中
iframeDocument.head.appendChild(scriptElement);
};
iframe.src = 'your-iframe-source.html';
document.body.appendChild(iframe);
上面的例子运行后会创建一个div渲染在iframe中。
现在需要解决一个问题,html是渲染在主应用的,但是js是在iframe中运行的,iframe中的js如何修改外面的dom呢?无界的做法是利用Proxy来代理iframe中的window和document等对象。我们对上面的代码做一些修改:
+ const proxyWindow = new Proxy(window, {
+ get: (target, p) => {
+ if(p === 'document') {
+ return window.document
+ }
+ return Reflect.get(...arguments)
+ }
+ })
const iframe = document.createElement('iframe');
// 监听iframe加载完成事件
iframe.onload = function () {
// 获取iframe文档对象
const iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
+ iframe.contentWindow.__PROXY_WINDOW__ = proxyWindow;
// 创建script标签
const scriptElement = iframeDocument.createElement('script');
scriptElement.type = 'text/javascript';
// 设置想要注入的JS代码
- const code = `
- const modal = document.createElement('div');
- modal.innerText = 'This is a modal';
- document.body.appendChild(modal);
- `;
+ const code = `
+ (function(window, self, global, document){
+
+ const modal = document.createElement('div');
+ modal.innerText = 'This is a modal';
+ document.body.appendChild(modal);
+
+ }).bind(__PROXY_WINDOW__)(
+ __PROXY_WINDOW__,
+ __PROXY_WINDOW__,
+ __PROXY_WINDOW__,
+ __PROXY_WINDOW__.document
+ )
+`;
try {
scriptElement.textContent = code;
} catch (e) {
scriptElement.text = code;
}
// 将script标签添加到iframe文档中
iframeDocument.head.appendChild(scriptElement);
};
iframe.src = 'your-iframe-source.html';
document.body.appendChild(iframe);
通过修改之后,在iframe中拿到的document对象就是父页面的document,无界把dom操作都代理到ShadowRoot上,这就是它的核心原理。
存在问题
为了实现路由同步,无界还代理了location对象,同时iframe加载子应用时需要把子应用的路由信息带在iframe的url上。
假设当前主应用在页面http://hostA/pathA/#hashA,需要加载子应用的页面为http://hostB/pathB/#/hashB,那么iframe的url就应该设置为http://hostA/pathB/#hashB,这样既保证了同源,又能使子应用知道需要切换到哪个路由。
但是这种实现会带来一个问题,设置了iframe的url之后,它会加载主应用的html和js,导致子应用被污染。无界在框架层面的做法是通过轮询的方式判断location.origin是否已经修改成功,然后终止对主应用资源的加载。但是仍然会有几率出现问题。无界对这个问题提供了解决方案:子应用 iframe 初始化时加载、执行了主应用的资源
其他
EMP
基于Rspack、Module Federation与Typescript,聚焦高性能与微前端的工程化解决方案。
对于EMP我没有做太多的了解,不过看它的文档,在常见的微前端的业务场景下,EMP基本不适用,而且用的人不多,社区不够活跃,文档比较一般。
Garfish
在Garfish官网有一段介绍:
那么如何提供一套解决方案既具备 SPA 的用户体验,又能够具备 MPA 应用带来的灵活性,并且可以实现应用间同灰度,监控也可能细化到子系统呢。目前在字节跳动内应用的微前端解决方案「Garfish」 ,解决方案主要分为三层:部署侧、框架运行时、调试工具,目前采用的是 SPA 的架构。
Garfish是一套很重的微前端解决方案,它不止是一个聚合不同技术栈子应用的框架,还包括他们内部的部署平台,调试工具。目前开源的应该是“框架运行时”部分。
字节内部的微前端需求毫无疑问使用Garfish是最佳选择,他们提供了一整套开发部署监控流程,但是对于广大开发者而言,实在看不出Garfish比qiankun,micro-app,无界有什么优势。