Garfish
是字节跳动 web infra
团队推出的一款微前端框架
包含构建微前端系统时所需要的基本能力,任意前端框架均可使用。接入简单,可轻松将多个前端应用组合成内聚的单个产品
因为当前对 Garfish
的解读极少,而微前端又是现代前端领域相当重要的一环,因此写下本文,同时也是对学习源码的一个总结
本文基于 garfish#0d4cc0c82269bce8422b0e9105b7fe88c2efe42a 进行解读
git clone https://github.com/modern-js-dev/garfish.git
cd garfish
pnpm install
pnpm build
pnpm dev
然后打开https://localhost:8090/
即可看到演示项目
export const GarfishInit = async () => {
try {
Garfish.run(Config);
} catch (error) {
console.log('garfish init error', error);
}
};
其中关键点是 Config
参数, 其所有参数都是可选的,一般比较重要的几个参数为:
basename
子应用的基础路径,默认值为 /,整个微前端应用的 basename。设置后该值为所有子应用的默认值,若子应用 AppInfo 中也提供了该值会替换全局的 basename 值domGetter
子应用挂载点。如'#submodule'
apps
需要主要参数如 name
, entry
, activeWhen(路由地址)
此函数运行之后,Garfish会自动进行路由劫持功能。根据路由变化
以react17为例:
import {
reactBridge, AppInfo } from '@garfish/bridge-react';
export const provider = reactBridge({
el: '#root', // 此处的root是子应用自己声明的root
// a promise that resolves with the react component. Wait for it to resolve before mounting
loadRootComponent: (appInfo: AppInfo) => {
return Promise.resolve(() => <RootComponent {
...appInfo} />);
},
errorBoundary: (e: any) => <ErrorBoundary />,
});
其中:
RootComponent
是子应用的主要逻辑reactBridge
是garfish导出的一个封装函数。大概的逻辑就是把react的一些特有写法映射到garfish
的通用生命周期,包含render
和destroy
那么简单了解了一些garfish的基本使用方案,我们就来看看garfish
在此过程中到底做了什么。
从Garfish.run
开始:
garfish/packages/core/src/garfish.ts
run(options: interfaces.Options = {
}) {
if (this.running) {
/**
* 重复运行检测
*/
if (__DEV__) {
warn('Garfish is already running now, Cannot run Garfish repeatedly.');
}
return this;
}
/**
* 全局化配置
*/
this.setOptions(options);
/**
* 载入插件
*/
// Register plugins
options.plugins?.forEach((plugin) => this.usePlugin(plugin));
// Put the lifecycle plugin at the end, so that you can get the changes of other plugins
this.usePlugin(GarfishOptionsLife(this.options, 'global-lifecycle'));
// Emit hooks and register apps
this.hooks.lifecycle.beforeBootstrap.emit(this.options); // 生命周期事件beforeBootstrap
this.registerApp(this.options.apps || []); // 注册子应用
this.running = true;
this.hooks.lifecycle.bootstrap.emit(this.options); // bootstrap
return this;
}
其中移除插件等内容,最重要的是registerApp
调用,用于将配置注册到实例中
接下来的代码会移除无关紧要的代码,仅保留核心逻辑
registerApp(list: interfaces.AppInfo | Array<interfaces.AppInfo>) {
if (!Array.isArray(list)) list = [list];
for (const appInfo of list) {
if (!this.appInfos[appInfo.name]) {
this.appInfos[appInfo.name] = appInfo;
}
}
return this;
}
看上去仅仅是一些配置设定,那么所谓的路由绑定是从哪里发生的呢?这一切其实早就暗中进行了处理。
export type {
interfaces } from '@garfish/core';
export {
default as Garfish } from '@garfish/core';
export {
GarfishInstance as default } from './instance';
export {
defineCustomElements } from './customElement';
当调用 import Garfish from 'garfish';
时, 使用的是默认创建好的全局Garfish实例。该逻辑简化版大概如下:
import {
GarfishRouter } from '@garfish/router';
import {
GarfishBrowserVm } from '@garfish/browser-vm';
import {
GarfishBrowserSnapshot } from '@garfish/browser-snapshot';
// Initialize the Garfish, currently existing environment to allow only one instance (export to is for test)
function createContext(): Garfish {
// Existing garfish instance, direct return
if (inBrowser() && window['__GARFISH__'] && window['Garfish']) {
return window['Garfish'];
}
const GarfishInstance = new Garfish({
plugins: [GarfishRouter(), GarfishBrowserVm(), GarfishBrowserSnapshot()],
});
type globalValue = boolean | Garfish | Record<string, unknown>;
const set = (namespace: string, val: globalValue = GarfishInstance) => {
// NOTE: 这里有一部分状态判定的逻辑,以及确保只读,这里是精简后的逻辑
window[namespace] = val;
};
if (inBrowser()) {
// Global flag
set('Garfish');
Object.defineProperty(window, '__GARFISH__', {
get: () => true,
configurable: __DEV__ ? true : false,
});
}
return GarfishInstance;
}
export const GarfishInstance = createContext();
其中核心逻辑为:
Garfish
实例,则直接从本地拿。(浏览器环境用于子应用,也可以从这边看出garfish
并不支持其他的js环境GarfishRouter
路由劫持能力GarfishBrowserVm
js运行时沙盒隔离GarfishBrowserSnapshot
浏览器状态快照Garfish
对象并标记__GARFISH__
, 注意该变量为只读其中安全和样式隔离的逻辑我们暂且不看,先看其核心插件 GarfishRouter
的实现
Garfish
自己实现了一套插件协议,其本质是pubsub模型的变种(部分生命周期的emit阶段增加了异步操作的等待逻辑)。
我们以Garfish
最核心的插件 @garfish/router
为学习例子,该代码的位置在: garfish/packages/router/src/index.ts
export function GarfishRouter(_args?: Options) {
return function (Garfish: interfaces.Garfish): interfaces.Plugin {
Garfish.apps = {
};
Garfish.router = router;
return {
name: 'router',
version: __VERSION__,
bootstrap(options: interfaces.Options) {
let activeApp: null | string = null;
const unmounts: Record<string, Function> = {
};
const {
basename } = options;
const {
autoRefreshApp = true, onNotMatchRouter = () => null } =
Garfish.options;
async function active(
appInfo: interfaces.AppInfo,
rootPath: string = '/',
) {
routerLog(`${
appInfo.name} active`, {
appInfo,
rootPath,
listening: RouterConfig.listening,
});
// In the listening state, trigger the rendering of the application
if (!RouterConfig.listening) return;
const {
name, cache = true, active } = appInfo;
if (active) return active(appInfo, rootPath);
appInfo.rootPath = rootPath;
const currentApp = (activeApp = createKey());
const app = await Garfish.loadApp(appInfo.name, {
basename: rootPath,
entry: appInfo.entry,
cache: true,
domGetter: appInfo.domGetter,
});
if (app) {
app.appInfo.basename = rootPath;
const call = async (app: interfaces.App, isRender: boolean) => {
if (!app) return;
const isDes = cache && app.mounted;
if (isRender) {
return await app[isDes ? 'show' : 'mount']();
} else {
return app[isDes ? 'hide' : 'unmount']();
}
};
Garfish.apps[name] = app;
unmounts[name] = () => {
// Destroy the application during rendering and discard the application instance
if (app.mounting) {
delete Garfish.cacheApps[name];
}
call(app, false);
};
if (currentApp === activeApp) {
await call(app, true);
}
}
}
async function deactive(appInfo: interfaces.AppInfo, rootPath: string) {
routerLog(`${
appInfo.name} deactive`, {
appInfo,
rootPath,
});
activeApp = null;
const {
name, deactive } = appInfo;
if (deactive) return deactive(appInfo, rootPath);
const unmount = unmounts[name];
unmount && unmount();
delete Garfish.apps[name];
// Nested scene to remove the current application of nested data
// To avoid the main application prior to application
const needToDeleteApps = router.routerConfig.apps.filter((app) => {
if (appInfo.rootPath === app.basename) return true;
}<