Garfish简介
Garfish是字节跳动开源的一款微前端框架,支持将单个产品拆分为一个主应用和若干个子应用,主应用和各个子应用能够分别进行开发、测试、部署。与之类似的框架有qiankun、MicroApp、无界等。Garfish的使用方式为:
- 主应用在入口处调用Garfish.run
js
import Garfish from 'garfish';
Garfish.run({
basename: '/',
domGetter: '#subApp',
apps: [
{
name: 'react',
activeWhen: '/react',
entry: 'http://localhost:3000', // html入口
},
{
name: 'vue',
activeWhen: '/vue',
entry: 'http://localhost:8080/index.js', // js入口
},
],
});
- 子应用在入口处导出一个provider函数
js
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Switch, Route, Link } from 'react-router-dom';
export const provider = () => ({
// render 渲染函数,必须提供
render: ({ dom, basename }) => {
// 和子应用独立运行时一样,将子应用渲染至对应的容器节点,根据不同的框架使用不同的渲染方式
ReactDOM.render(
<React.StrictMode>
<App basename={basename} />
</React.StrictMode>,
// 需要注意的一点是,子应用的入口是否为 HTML 类型(即在主应用的中配置子应用的 entry 地址为子应用的 html 地址),
// 如果为 HTML 类型,需要在 dom 的基础上选中子应用的渲染节点
// 如果为 JS 类型,则直接将 dom 作为渲染节点即可
dom.querySelector('#root'),
);
},
// destroy 应用销毁函数,必须提供
destroy: ({ dom, basename }) => {
// 使用框架提供的销毁函数销毁整个应用,已达到销毁框架中可能存在得副作用,并触发应用中的一些组件销毁函数
// 需要注意的时一定要保证对应框架得销毁函数使用正确,否则可能导致子应用未正常卸载影响其他子应用
ReactDOM.unmountComponentAtNode(
dom ? dom.querySelector('#root') : document.querySelector('#root'),
);
},
});
从主应用导入Garfish、调用Garfish.run,到子应用加载并显示,其大致流程如下所示

主应用启动Garfish
Garfish采用了单例模式,主应用通过import Garfish from 'garfish';
获取的是一个单例对象
js
// Initialize the Garfish, currently existing environment to allow only one instance (export to is for test)
function createContext(): Garfish {
let fresh = false;
// 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) => {
if (hasOwn(window, namespace)) {
if (!(window[namespace] && window[namespace].flag === __GARFISH_FLAG__)) {
const next = () => {
fresh = true;
if (__DEV__) {
warn(`"Window.${namespace}" will be overwritten by "garfish".`);
}
};
const desc = Object.getOwnPropertyDescriptor(window, namespace);
if (desc) {
if (desc.configurable) {
def(window, namespace, val);
next();
} else if (desc.writable) {
window[namespace] = val;
next();
}
}
}
} else {
fresh = true;
def(window, namespace, val);
}
};
if (inBrowser()) {
// Global flag
set('Garfish');
def(window, '__GARFISH__', true);
}
if (fresh) {
if (__DEV__) {
if (__VERSION__ !== window['Garfish'].version) {
warn(
'The "garfish version" used by the main and sub-applications is inconsistent.',
);
}
}
}
return GarfishInstance;
}
createContext在创建Garfish对象之后,将其挂在了window上,后续直接通过window['Garfish']
获取。
导入Garfish之后,主应用通过Garfish.run来进行启动
js
export class Garfish extends EventEmitter2 {
...
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);
this.registerApp(this.options.apps || []);
this.running = true;
this.hooks.lifecycle.bootstrap.emit(this.options);
return this;
}
...
}
在run方法中主要进行了选项设置、插件注册、子应用注册、钩子触发。
bootstrap钩子触发路由插件
Garfish插件系统
Garfish设计了一套基于发布订阅模式的插件系统。以Garfish类为例,在Garfish类中定义了一套钩子(hooks),其中的每一个钩子都是一个发布者,会在不同的时机进行发布(emit)。
js
export function globalLifecycle() {
return new PluginSystem({
beforeBootstrap: new SyncHook<[interfaces.Options], void>(),
bootstrap: new SyncHook<[interfaces.Options], void>(),
beforeRegisterApp: new SyncHook<[interfaces.AppInfo | Array<interfaces.AppInfo>], void>(),
registerApp: new SyncHook<[Record<string, interfaces.AppInfo>], void>(),
beforeLoad: new AsyncHook<[interfaces.AppInfo]>(),
afterLoad: new AsyncHook<[interfaces.AppInfo, interfaces.App | null]>(),
errorLoadApp: new SyncHook<[Error, interfaces.AppInfo], void>(),
});
}
export class Garfish extends EventEmitter2 {
...
public hooks = globalLifecycle();
...
run(options: interfaces.Options = {}) {
if (this.running) {
if (__DEV__) {
warn('Garfish is already running now, Cannot run Garfish repeatedly.');
}
return this;
}
this.setOptions(options);
options.plugins?.forEach((plugin) => this.usePlugin(plugin));
this.usePlugin(GarfishOptionsLife(this.options, 'global-lifecycle'));
// 触发beforeBootstrap钩子
this.hooks.lifecycle.beforeBootstrap.emit(this.options);
this.registerApp(this.options.apps || []);
this.running = true;
// 触发bootstrap钩子
this.hooks.lifecycle.bootstrap.emit(this.options);
return this;
}
...
}
而插件(plugin)则可以通过定义与特定钩子同名的函数来订阅相应的钩子,当钩子触发(emit)时就会执行该函数。以路由插件GarfishRouter为例,该插件订阅了bootstrap和registerApp两个钩子:
js
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) {
...
},
registerApp(appInfos) {
...
},
};
};
}
Garfish单例在创建时会注册一些内部插件,如路由插件GarfishRouter、vm沙箱插件GarfishBrowserVm、快照沙箱插件GarfishBrowserSnapshot等等。Garfish也支持用户注册自定义插件。
路由插件GarfishRouter
在Garfish.run的最后阶段会触发bootstrap钩子,路由插件GarfishRouter中定义的bootstrap函数会在此时被执行
js
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 = '/',
) {
...
}
async function deactive(appInfo: interfaces.AppInfo, rootPath: string) {
...
}
const apps = Object.values(Garfish.appInfos);
const appList = apps.filter((app) => {
if (!app.basename) app.basename = basename;
return !!app.activeWhen;
}) as Array<Required<interfaces.AppInfo>>;
const listenOptions = {
basename,
active,
deactive,
autoRefreshApp,
notMatch: onNotMatchRouter,
apps: appList,
listening: true,
};
routerLog('listenRouterAndReDirect', listenOptions);
listenRouterAndReDirect(listenOptions);
}
export const listenRouterAndReDirect = ({
apps,
basename = '/',
autoRefreshApp,
active,
deactive,
notMatch,
listening = true,
}: Options) => {
// 注册子应用、注册激活、销毁钩子
registerRouter(apps);
// 初始化信息
setRouterConfig({
basename,
autoRefreshApp,
// supportProxy: !!window.Proxy,
active,
deactive,
notMatch,
listening,
});
// 开始监听路由变化触发、子应用更新。重载默认初始子应用
listen();
};
export const listen = () => {
// 劫持pushState、replaceState、popstate事件
normalAgent();
// 加载初始URL对应的子应用
initRedirect();
};
在路由插件中,有一个全局对象RouterConfig,其中记录了basename、当前URL路径及子应用、注册的所有子应用等信息。在listenRouterAndReDirect中,setRouterConfig用于设置该对象。listen则有两个作用:
- 通过normalAgent劫持pushState、replaceState和popstate事件
- 通过initRedirect加载初始URL对应的子应用
js
export const normalAgent = () => {
// By identifying whether have finished listening, if finished listening, listening to the routing changes do not need to hijack the original event
// Support nested scene
const addRouterListener = function () {
window.addEventListener(__GARFISH_BEFORE_ROUTER_EVENT__, function (env) {
RouterConfig.routerChange && RouterConfig.routerChange(location.pathname);
linkTo((env as any).detail);
});
};
if (!window[__GARFISH_ROUTER_FLAG__]) {
// Listen for pushState and replaceState, call linkTo, processing, listen back
// Rewrite the history API method, triggering events in the call
const rewrite = function (type: keyof History) {
const hapi = history[type];
return function (this: History) {
const urlBefore = window.location.pathname + window.location.hash;
const stateBefore = history?.state;
const res = hapi.apply(this, arguments);
const urlAfter = window.location.pathname + window.location.hash;
const stateAfter = history?.state;
const e = createEvent(type);
(e as any).arguments = arguments;
if (
urlBefore !== urlAfter ||
JSON.stringify(stateBefore) !== JSON.stringify(stateAfter)
) {
window.dispatchEvent(
new CustomEvent(__GARFISH_BEFORE_ROUTER_EVENT__, {
detail: {
toRouterInfo: {
fullPath: urlAfter,
href: location.href,
query: parseQuery(location.search),
path: getPath(RouterConfig.basename!, urlAfter),
state: stateAfter,
},
fromRouterInfo: {
fullPath: urlBefore,
query: RouterConfig.current!.query,
href: RouterConfig.current!.href,
path: getPath(RouterConfig.basename!, urlBefore),
state: stateBefore,
},
eventType: type,
},
}),
);
}
// window.dispatchEvent(e);
return res;
};
};
history.pushState = rewrite('pushState');
history.replaceState = rewrite('replaceState');
// Before the collection application sub routing, forward backward routing updates between child application
window.addEventListener(
'popstate',
function (event) {
// Stop trigger collection function, fire again match rendering
if (event && typeof event === 'object' && (event as any).garfish)
return;
if (history.state && typeof history.state === 'object')
delete history.state[__GARFISH_ROUTER_UPDATE_FLAG__];
window.dispatchEvent(
new CustomEvent(__GARFISH_BEFORE_ROUTER_EVENT__, {
detail: {
toRouterInfo: {
fullPath: location.pathname,
href: location.href,
query: parseQuery(location.search),
path: getPath(RouterConfig.basename!),
},
fromRouterInfo: {
fullPath: RouterConfig.current!.fullPath,
path: getPath(
RouterConfig.basename!,
RouterConfig.current!.path,
),
href: RouterConfig.current!.href,
query: RouterConfig.current!.query,
},
eventType: 'popstate',
},
}),
);
},
false,
);
window[__GARFISH_ROUTER_FLAG__] = true;
}
addRouterListener();
};
export const initRedirect = () => {
linkTo({
toRouterInfo: {
fullPath: location.pathname,
href: location.href,
path: getPath(RouterConfig.basename!),
query: parseQuery(location.search),
state: history.state,
},
fromRouterInfo: {
fullPath: '/',
href: '',
path: '/',
query: {},
state: {},
},
eventType: 'pushState',
});
};
normalAgent劫持浏览器的路由变化,并触发自定义事件__GARFISH_BEFORE_ROUTER_EVENT__,在事件中记录了路由变化前后的URL信息以及触发路由变化的原因(pushState、replaceState、popstate事件)。通过监听该自定义事件,Garfish能够感知路由变化并通过linkTo来处理子应用的变更。
在初始化时,Garfish会在initRedirect中根据初始URL调用一次linkTo,以加载初始子应用。
js
export const linkTo = async ({
toRouterInfo,
fromRouterInfo,
eventType,
}: {
toRouterInfo: RouterInfo;
fromRouterInfo: RouterInfo;
eventType: keyof History | 'popstate';
}) => {
const {
current,
apps,
deactive,
active,
notMatch,
beforeEach,
afterEach,
autoRefreshApp,
} = RouterConfig;
const deactiveApps = current!.matched.filter(
(appInfo) =>
!hasActive(
appInfo.activeWhen,
getPath(appInfo.basename, location.pathname),
),
);
// Activate the corresponding application
const activeApps = apps.filter((appInfo) => {
return hasActive(
appInfo.activeWhen,
getPath(appInfo.basename, location.pathname),
);
});
const needToActive = activeApps.filter(({ name }) => {
return !current!.matched.some(({ name: cName }) => name === cName);
});
// router infos
const to = {
...toRouterInfo,
matched: needToActive,
};
const from = {
...fromRouterInfo,
matched: deactiveApps,
};
await toMiddleWare(to, from, beforeEach!);
// Pause the current application of active state
if (current!.matched.length > 0) {
await asyncForEach(
deactiveApps,
async (appInfo) =>
await deactive(appInfo, getPath(appInfo.basename, location.pathname)),
);
}
setRouterConfig({
current: {
path: getPath(RouterConfig.basename!),
fullPath: location.pathname,
href: location.href,
matched: activeApps,
state: history.state,
query: parseQuery(location.search),
},
});
// Within the application routing jump, by collecting the routing function for processing.
// Filtering gar-router popstate hijacking of the router
// In the switch back and forth in the application is provided through routing push method would trigger application updates
// application will refresh when autoRefresh configuration to true
const curState = window.history.state || {};
if (
eventType !== 'popstate' &&
(curState[__GARFISH_ROUTER_UPDATE_FLAG__] || autoRefreshApp)
) {
callCapturedEventListeners(eventType);
}
await asyncForEach(needToActive, async (appInfo) => {
// Function using matches character and routing using string matching characters
const appRootPath = getAppRootPath(appInfo);
await active(appInfo, appRootPath);
});
if (activeApps.length === 0 && notMatch) notMatch(location.pathname);
await toMiddleWare(to, from, afterEach!);
};
在linkTo中,会对需要激活的子应用调用active函数。active是定义在路由插件的bootstrap函数中的:
js
bootstrap(options: interfaces.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, active, cache = true } = appInfo;
if (active) return active(appInfo, rootPath);
appInfo.rootPath = rootPath;
const currentApp = (activeApp = createKey());
const app = await Garfish.loadApp(appInfo.name, {
cache,
basename: rootPath,
entry: appInfo.entry,
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);
}
}
}
...
}
在active函数中,会通过Garfish.loadApp加载子应用资源、获取子应用的App实例,并触发子应用实例的mount生命周期。
Garfish.loadApp加载子应用资源
Garfish.loadApp的完整代码如下:
js
export class Garfish extends EventEmitter2 {
...
loadApp(
appName: string,
options?: Partial<Omit<interfaces.AppInfo, 'name'>>,
): Promise<interfaces.App | null> {
assert(appName, 'Miss appName.');
let appInfo = generateAppOptions(appName, this, options);
const asyncLoadProcess = async () => {
// Return not undefined type data directly to end loading
const stop = await this.hooks.lifecycle.beforeLoad.emit(appInfo);
if (stop === false) {
warn(`Load ${appName} application is terminated by beforeLoad.`);
return null;
}
//merge configs again after beforeLoad for the reason of app may be re-registered during beforeLoad resulting in an incorrect information
appInfo = generateAppOptions(appName, this, appInfo);
assert(
appInfo.entry,
`Can't load unexpected child app "${appName}", ` +
'Please provide the entry parameters or registered in advance of the app.',
);
// Existing cache caching logic
let appInstance: interfaces.App | null = null;
const cacheApp = this.cacheApps[appName];
if (appInfo.cache && cacheApp) {
appInstance = cacheApp;
} else {
try {
const [manager, resources, isHtmlMode] = await processAppResources(
this.loader,
appInfo,
);
appInstance = new App(
this,
appInfo,
manager,
resources,
isHtmlMode,
appInfo.customLoader,
);
// The registration hook will automatically remove the duplication
for (const key in this.plugins) {
appInstance.hooks.usePlugin(this.plugins[key]);
}
if (appInfo.cache) {
this.cacheApps[appName] = appInstance;
}
} catch (e) {
__DEV__ && warn(e);
this.hooks.lifecycle.errorLoadApp.emit(e, appInfo);
}
}
await this.hooks.lifecycle.afterLoad.emit(appInfo, appInstance);
return appInstance;
};
if (!this.loading[appName]) {
this.loading[appName] = asyncLoadProcess().finally(() => {
delete this.loading[appName];
});
}
return this.loading[appName];
}
...
}
其主要作用有:
- 合并全局配置和子应用配置,得到该子应用的最终配置
- 判断是否可以使用缓存
- 获取子应用entry资源以及所需的静态资源
- 初始化子应用App实例、注册插件(App类也定义了一系列钩子),并返回该实例。
资源加载是通过processAppResources函数实现的
js
export async function processAppResources(loader: Loader, appInfo: AppInfo) {
let isHtmlMode: Boolean = false,
fakeEntryManager;
const resources: any = { js: [], link: [], modules: [] }; // Default resources
assert(appInfo.entry, `[${appInfo.name}] Entry is not specified.`);
const { resourceManager: entryManager } = await loader.load({
scope: appInfo.name,
url: transformUrl(location.href, appInfo.entry),
});
// Html entry
if (entryManager instanceof loader.TemplateManager) {
isHtmlMode = true;
const [js, link, modules] = await fetchStaticResources(
appInfo.name,
loader,
entryManager,
);
resources.js = js;
resources.link = link;
resources.modules = modules;
} else if (entryManager instanceof loader.JavaScriptManager) {
// Js entry
isHtmlMode = false;
const mockTemplateCode = `<script src="${entryManager.url}"></script>`;
fakeEntryManager = new loader.TemplateManager(
mockTemplateCode,
entryManager.url,
);
entryManager.setDep(fakeEntryManager.findAllJsNodes()[0]);
resources.js = [entryManager];
} else {
error(`Entrance wrong type of resource of "${appInfo.name}".`);
}
return [fakeEntryManager || entryManager, resources, isHtmlMode];
}
processAppResources会首先请求子应用entry对应的资源,并判断是HTML entry还是JS entry。如果是HTML entry,Garfish会根据返回的HTML生成AST,并请求其所需的所有静态资源(JS、CSS、remote module),以供后续使用。如果是JS entry,Garfish也会将其模拟成只有一个script标签的HTML,以便后续可以采用相同的方式处理。
子应用挂载(mount)
Garfish.loadApp完成之后,路由插件的bootstrap中定义的active函数会继续执行并调用子应用实例的mount方法,进行子应用的渲染。
js
export class App {
...
async mount() {
if (!this.canMount()) return false;
this.hooks.lifecycle.beforeMount.emit(this.appInfo, this, false);
this.active = true;
this.mounting = true;
try {
this.context.activeApps.push(this);
// Because the 'unmount' lifecycle will reset 'customExports'
// so we should initialize async registration while mounting
this.initAsyncProviderRegistration();
// add container and compile js with cjs
const { asyncScripts, deferScripts } =
await this.compileAndRenderContainer();
if (!this.stopMountAndClearEffect()) return false;
// The defer script is still a synchronous code and needs to be placed before `getProvider`
deferScripts();
// Good provider is set at compile time
const provider = await this.getProvider();
// Existing asynchronous functions need to decide whether the application has been unloaded
if (!this.stopMountAndClearEffect()) return false;
this.callRender(provider, true);
this.display = true;
this.mounted = true;
this.hooks.lifecycle.afterMount.emit(this.appInfo, this, false);
// Run async scripts
await asyncScripts;
if (!this.stopMountAndClearEffect()) return false;
} catch (e) {
this.entryManager.DOMApis.removeElement(this.appContainer);
this.hooks.lifecycle.errorMountApp.emit(e, this.appInfo);
return false;
} finally {
this.mounting = false;
}
return true;
}
...
}
HTML模板渲染
子应用HTML模板的渲染是由compileAndRenderContainer中的renderTemplate完成的
js
async compileAndRenderContainer() {
// Render the application node
// If you don't want to use the CJS export, at the entrance is not can not pass the module, the require
await this.renderTemplate();
const execScript = (type: 'async' | 'defer') => {
...
};
// Execute asynchronous script and defer script
return {
deferScripts: () => execScript('defer'),
asyncScripts: new Promise<void>((resolve) => {
// Asynchronous script does not block the rendering process
setTimeout(() => {
if (this.stopMountAndClearEffect()) {
execScript('async');
}
resolve();
});
}),
};
}
private async renderTemplate() {
const { appInfo, entryManager, resources } = this;
const { url: baseUrl, DOMApis } = entryManager;
const { htmlNode, appContainer } = createAppContainer(appInfo);
// The base url is fixed by default, so it is set when the sandbox is closed or the fix base url is not false
const fixStaticResourceBaseUrl =
!this.appInfo.sandbox ||
(this.appInfo.sandbox &&
this.appInfo.sandbox.fixStaticResourceBaseUrl === true);
// Transformation relative path
this.htmlNode = htmlNode;
this.appContainer = appContainer;
// To append to the document flow, recursive again create the contents of the HTML or execute the script
await this.addContainer();
const customRenderer: Parameters<typeof entryManager.createElements>[0] = {
...
};
// Render dom tree and append to document
entryManager.createElements(customRenderer, htmlNode, (node, parent) => {
// Trigger a custom render hook
return this.hooks.lifecycle.customRender.emit({
node,
parent,
app: this,
customElement: null,
});
});
}
export function createAppContainer(appInfo: interfaces.AppInfo) {
// Create a temporary node, which is destroyed by the module itself
let htmlNode: HTMLDivElement | HTMLHtmlElement =
document.createElement('div');
const appContainer = document.createElement('div');
if (appInfo.sandbox && appInfo.sandbox.strictIsolation) {
htmlNode = document.createElement('html');
const root = appContainer.attachShadow({ mode: 'open' });
root.appendChild(htmlNode);
// asyncNodeAttribute(htmlNode, document.body);
dispatchEvents(root);
} else {
htmlNode.setAttribute(__MockHtml__, '');
appContainer.appendChild(htmlNode);
}
appContainer.id = `${appContainerId}_${appInfo.name}_${createKey()}`;
return {
htmlNode,
appContainer,
};
}
private async addContainer() {
// Initialize the mount point, support domGetter as promise, is advantageous for the compatibility
const wrapperNode = await getRenderNode(this.appInfo.domGetter);
if (typeof wrapperNode.appendChild === 'function') {
wrapperNode.appendChild(this.appContainer);
}
}
renderTemplate先通过createAppContainer创建htmlNode和appContainer两个DOM元素。这两个元素根据strictIsolation配置的不同有不同的实现:
- 当strictIsolation为false时,appContainer和htmlNode都是div元素,appContainer是htmlNode的父节点,htmlNode用于模拟子应用的html节点。
- 当strictIsolation为true时,子应用会放在shadow dom中,appContainer是div元素,htmlNode是放置在appContainer的shadow root中的html节点。
在创建htmlNode和appContainer之后,Garfish会通过子应用配置中的domGetter获取appContainer的挂载点并将其挂载上去。最后通过entryManager.createElements遍历AST,生成子应用DOM树,并挂载在htmlNode之下。
在遍历AST时,Garfish通过customRenderer对部分节点进行了特殊处理,其中比较关键的有body、head、link和script。
- body、head:会判断strictIsolation,如果是true则生成body和head元素,否则用div来代替
- link:如果其rel属性是stylesheet(代表CSS资源),则会生成style节点,并将Garfish.loadApp时请求到的CSS代码放入style节点中
- script:脚本会区分为defer脚本、async脚本以及普通脚本。普通脚本会在遍历到对应script标签时就同步执行,而defer和async脚本会在子应用DOM树生成完成之后执行。由于脚本是Garfish自己在沙箱内执行的,因此脚本都会渲染成comment node。
获取子应用provider
在defer脚本执行完成之后,Garfish通过子应用实例的checkAndGetProvider获取子应用入口导出的provider函数
js
export class App {
...
private async checkAndGetProvider() {
const { appInfo, rootElement, cjsModules, customExports } = this;
const { name, props, basename } = appInfo;
let provider:
| ((...args: any[]) => interfaces.Provider)
| interfaces.Provider
| undefined = undefined;
// esModule export
await this.esmQueue.awaitCompletion();
// Cjs exports
if (cjsModules.exports) {
if (isPromise(cjsModules.exports))
cjsModules.exports = await cjsModules.exports;
// Is not set in the configuration of webpack library option
if (cjsModules.exports.provider) provider = cjsModules.exports.provider;
}
// Custom export prior to export by default
if (customExports.provider) {
provider = customExports.provider;
}
// async provider
if (this.asyncProviderTimeout && !provider) {
// this child app needs async provider registration
provider = await this.awaitAsyncProviderRegistration();
}
if (typeof provider === 'function') {
provider = await provider(
{
basename,
dom: rootElement,
...(props || {}),
},
props,
);
} else if (isPromise(provider)) {
provider = await provider;
}
// The provider may be a function object
if (!isObject(provider) && typeof provider !== 'function') {
warn(
` Invalid module content: ${name}, you should return both render and destroy functions in provider function.`,
);
}
// If you have customLoader, the dojo.provide by user
const hookRes = await (this.customLoader &&
this.customLoader(provider as interfaces.Provider, appInfo, basename));
if (hookRes) {
const { mount, unmount } = hookRes || ({} as any);
if (typeof mount === 'function' && typeof unmount === 'function') {
(provider as interfaces.Provider).render = mount;
(provider as interfaces.Provider).destroy = unmount;
}
}
if (!appInfo.noCheckProvider) {
assert(provider, `"provider" is "${provider}".`);
// No need to use "hasOwn", because "render" may be on the prototype chain
assert('render' in provider, '"render" is required in provider.');
assert('destroy' in provider, '"destroy" is required in provider.');
}
this.provider = provider as interfaces.Provider;
return provider as interfaces.Provider;
}
...
}
一般情况下,provider是通过appInstance.cjsModules.exports
获取的。而子应用入口导出的内容是在脚本执行期间传递给appInstance.cjsModules.exports
的。
首先,在子应用实例创建时,会初始化appInstance.cjsModules
:
js
export class App {
...
constructor(
context: Garfish,
appInfo: AppInfo,
entryManager: TemplateManager,
resources: interfaces.ResourceModules,
isHtmlMode: boolean,
customLoader?: CustomerLoader,
) {
...
this.cjsModules = {
exports: {},
module: null,
require: (key: string) => {
const pkg = this.global[key] || context.externals[key] || window[key];
if (!pkg) {
warn(`Package "${key}" is not found`);
}
return pkg;
},
};
this.cjsModules.module = this.cjsModules;
...
}
...
}
而脚本执行是通过execScript函数实现的:
js
export class App {
...
execScript(
code: string,
env: Record<string, any>,
url?: string,
options?: interfaces.ExecScriptOptions,
) {
env = {
...this.getExecScriptEnv(options?.noEntry),
...(env || {}),
};
this.scriptCount++;
const args = [this.appInfo, code, env, url, options] as const;
this.hooks.lifecycle.beforeEval.emit(...args);
try {
this.runCode(code, env, url, options);
} catch (err) {
this.hooks.lifecycle.errorExecCode.emit(err, ...args);
throw err;
}
this.hooks.lifecycle.afterEval.emit(...args);
}
getExecScriptEnv(noEntry?: boolean) {
// The legacy of commonJS function support
const envs = {
[__GARFISH_EXPORTS__]: this.customExports,
[__GARFISH_GLOBAL_ENV__]: this.globalEnvVariables,
};
if (noEntry) {
return {
...envs,
require: this.cjsModules.require,
};
}
return {
...envs,
...this.cjsModules,
};
}
...
}
noEntry在一般情况下为false,因此execScript中,env.module
引用的对象是appInstance.cjsModules
。之后env会被传递给runCode函数。默认情况下,Garfish使用的是vm沙箱,vm沙箱会将App实例的runCode函数替换为vm沙箱Sandbox实例的execScript函数。Sandbox.execScript最终通过evalWithEnv函数执行脚本:
js
export function evalWithEnv(
code: string,
params: Record<string, any>,
context: any,
useStrict = false,
) {
const keys = Object.keys(params);
const nativeWindow = (0, eval)('window;');
// No random value can be used, otherwise it cannot be reused as a constant string
const randomValKey = '__garfish__exec_temporary__';
const values = keys.map((k) => `window.${randomValKey}.${k}`);
const contextKey = '__garfish_exec_temporary_context__';
try {
nativeWindow[randomValKey] = params;
nativeWindow[contextKey] = context;
const evalInfo = [
`;(function(${keys.join(',')}){${useStrict ? '"use strict";' : ''}`,
`\n}).call(window.${contextKey},${values.join(',')});`,
];
const internalizeString = internFunc(evalInfo[0] + code + evalInfo[1]);
// (0, eval) This expression makes the eval under the global scope
(0, eval)(internalizeString);
} catch (e) {
throw e;
} finally {
delete nativeWindow[randomValKey];
delete nativeWindow[contextKey];
}
}
经过一系列中间转换过程,在执行(0, eval)(internalizeString)
之前,env被赋值给了window.__garfish__exec_temporary__.__GARFISH_SANDBOX_ENV_VAR__
,因此window.__garfish__exec_temporary__.__GARFISH_SANDBOX_ENV_VAR__.module
是指向appInstance.cjsModules
的。而要执行的代码也会被Garfish进行一系列处理,最终internalizeString的情况如下所示:
js
(function (
window,
WebSocket,
XMLHttpRequest,
fetch,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
setImmediate,
history,
History,
document,
addEventListener,
removeEventListener,
MutationObserver,
MouseEvent,
localStorage,
sessionStorage,
__GARFISH_SANDBOX_ENV_VAR__
) {
with (window) {
...
let __GARFISH_EXPORTS__ = __GARFISH_SANDBOX_ENV_VAR__.__GARFISH_EXPORTS__;
let __GARFISH_GLOBAL_ENV__ =
__GARFISH_SANDBOX_ENV_VAR__.__GARFISH_GLOBAL_ENV__;
let exports = __GARFISH_SANDBOX_ENV_VAR__.exports;
// 可见这里的module是 window.__garfish__exec_temporary__.__GARFISH_SANDBOX_ENV_VAR__.module,
// 也就是appInstance.cjsModules
let module = __GARFISH_SANDBOX_ENV_VAR__.module;
let require = __GARFISH_SANDBOX_ENV_VAR__.require;
// 前面部分都是Garfish添加的,中间部分才是请求到的脚本内容
// Garfish要求子应用的构建配置中,libraryTarget使用umd,因此子应用脚本必然是下面的格式
(function webpackUniversalModuleDefinition(root, factory) {
if (typeof exports === "object" && typeof module === "object")
// 这里将appInstance.cjsModules.exports赋值为子应用的导出内容
module.exports = factory();
else if (typeof define === "function" && define.amd) define([], factory);
else {
var a = factory();
for (var i in a)
(typeof exports === "object" ? exports : root)[i] = a[i];
}
})(window, () => {
return ...
});
// 后面部分都是Garfish添加的,中间部分才是请求到的脚本内容
}
}).call(
window.__garfish_exec_temporary_context__,
window.__garfish__exec_temporary__.window,
window.__garfish__exec_temporary__.WebSocket,
window.__garfish__exec_temporary__.XMLHttpRequest,
window.__garfish__exec_temporary__.fetch,
window.__garfish__exec_temporary__.setTimeout,
window.__garfish__exec_temporary__.clearTimeout,
window.__garfish__exec_temporary__.setInterval,
window.__garfish__exec_temporary__.clearInterval,
window.__garfish__exec_temporary__.setImmediate,
window.__garfish__exec_temporary__.history,
window.__garfish__exec_temporary__.History,
window.__garfish__exec_temporary__.document,
window.__garfish__exec_temporary__.addEventListener,
window.__garfish__exec_temporary__.removeEventListener,
window.__garfish__exec_temporary__.MutationObserver,
window.__garfish__exec_temporary__.MouseEvent,
window.__garfish__exec_temporary__.localStorage,
window.__garfish__exec_temporary__.sessionStorage,
window.__garfish__exec_temporary__.__GARFISH_SANDBOX_ENV_VAR__
);
根据上面的注释可见,这段代码执行之后,子应用的导出内容会被赋值给appInstance.cjsModules.exports
,因此checkAndGetProvider函数可以从中获取provider。
调用provider.render
获取provider之后,Garfish通过调用provider.render完成子应用内容的渲染。以React子应用为例,一般会在provider.render中调用ReactDOM.render,将根组件挂载到指定的节点上。
在调用provider.render、等待async脚本执行完成之后,子应用mount即完成。至此,子应用加载的全流程已完成,子应用的内容最终显示在浏览器中。
子应用生命周期
前面的路由部分提到Garfish在路由变化时会通过linkTo函数来处理子应用的变更。对于要显示的子应用会调用active函数,不再显示的子应用调用deactive函数。active和deactive都定义在路由插件的bootstrap中
js
export function GarfishRouter(_args?: Options) {
return function (Garfish: interfaces.Garfish): interfaces.Plugin {
Garfish.apps = {};
Garfish.router = router;
return {
...
bootstrap(options: interfaces.Options) {
...
const unmounts: Record<string, Function> = {};
...
async function active(
appInfo: interfaces.AppInfo,
rootPath: string = '/',
) {
...
const currentApp = (activeApp = createKey());
const app = await Garfish.loadApp(appInfo.name, {
cache,
basename: rootPath,
entry: appInfo.entry,
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) {
...
const unmount = unmounts[name];
unmount && unmount();
delete Garfish.apps[name];
...
}
...
},
...
};
};
}
active和deactive都会调用call函数,在call函数中触发子应用的生命周期,完成子应用的挂载、卸载。子应用一共有四个生命周期:mount、unmount、show、hide。子应用初次加载时,触发的一定是mount。后续触发情况和缓存选项cache有关:
- 当cache为true时(默认情况),子应用卸载会触发hide,再次加载会触发show
- 当cache为false时,子应用卸载会触发unmount,再次加载会触发mount
mount在前面已经介绍过了,这里介绍一下另外三个生命周期
js
export class App {
...
async show() {
this.active = true;
const { display, mounted, provider } = this;
if (display) return false;
if (!mounted) {
__DEV__ && warn('Need to call the "app.mount()" method first.');
return false;
}
this.hooks.lifecycle.beforeMount.emit(this.appInfo, this, true);
this.context.activeApps.push(this);
await this.addContainer();
this.callRender(provider, false);
this.display = true;
this.hooks.lifecycle.afterMount.emit(this.appInfo, this, true);
return true;
}
hide() {
this.active = false;
this.mounting = false;
const { display, mounted, provider } = this;
if (!display) return false;
if (!mounted) {
__DEV__ && warn('Need to call the "app.mount()" method first.');
return false;
}
this.hooks.lifecycle.beforeUnmount.emit(this.appInfo, this, true);
this.callDestroy(provider, false);
this.display = false;
remove(this.context.activeApps, this);
this.hooks.lifecycle.afterUnmount.emit(this.appInfo, this, true);
return true;
}
unmount() {
this.active = false;
this.mounting = false;
if (!this.mounted || !this.appContainer) {
return false;
}
if (this.unmounting) {
__DEV__ && warn(`The ${this.name} app unmounting.`);
return false;
}
// This prevents the unmount of the current app from being called in "provider.destroy"
this.unmounting = true;
this.hooks.lifecycle.beforeUnmount.emit(this.appInfo, this, false);
try {
this.callDestroy(this.provider, true);
this.display = false;
this.mounted = false;
this.provider = undefined;
this.customExports = {};
this.cjsModules.exports = {};
this.esModuleLoader.destroy();
remove(this.context.activeApps, this);
this.hooks.lifecycle.afterUnmount.emit(this.appInfo, this, false);
} catch (e) {
remove(this.context.activeApps, this);
this.entryManager.DOMApis.removeElement(this.appContainer);
this.hooks.lifecycle.errorUnmountApp.emit(e, this.appInfo);
return false;
} finally {
this.unmounting = false;
}
return true;
}
// Call to destroy do compatible with two different sandbox
private callDestroy(provider?: interfaces.Provider, isUnmount?: boolean) {
const { rootElement, appContainer } = this;
if (provider && provider.destroy) {
provider.destroy({
appName: this.appInfo.name,
dom: rootElement,
appRenderInfo: { isUnmount },
props: this.appInfo.props,
});
}
this.entryManager.DOMApis.removeElement(appContainer);
}
...
}
unmount和hide都会调用provider.destroy,然后移除用于挂载子应用DOM树的appContainer。与hide相比,unmount会重置更多的状态。show会重新挂载appContainer,并调用provider.render渲染子应用内容。