布景
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环境 - 创立Garfish实例,并安装插件:
-
GarfishRouter
路由劫持能力 -
GarfishBrowserVm
js运转时沙盒阻隔 -
GarfishBrowserSnapshot
浏览器状况快照
-
- 在window上设置大局
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;
});
if (needToDeleteApps.length > 0) {
needToDeleteApps.forEach((app) => {
delete Garfish.appInfos[app.name];
delete Garfish.cacheApps[app.name];
});
router.setRouterConfig({
apps: router.routerConfig.apps.filter((app) => {
return !needToDeleteApps.some(
(needDelete) => app.name === needDelete.name,
);
}),
});
}
}
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);
},
registerApp(appInfos) {
const appList = Object.values(appInfos);
// @ts-ignore
router.registerRouter(appList.filter((app) => !!app.activeWhen));
// After completion of the registration application, trigger application mount
// Has been running after adding routing to trigger the redirection
if (!Garfish.running) return;
routerLog('registerApp initRedirect', appInfos);
initRedirect();
},
};
};
}
一个插件的结构形如 (context: Garfish) => Plugin
其间 Plugin
类型为一个目标,包括各个阶段的生命周期以及name
/version
等插件信息描述特点。
以 router
插件为例,其作用在bootstrap
和registerApp
两个生命周期阶段
生命周期界说可以在这里看到: garfish/packages/core/src/lifecycle.ts
以 Garfish.run
视角来看,履行次序为: beforeBootstrap -> beforeRegisterApp -> registerApp -> bootstrap -> ...
因而咱们先看registerApp
的逻辑。
registerApp 阶段
this.hooks.lifecycle.registerApp.emit(currentAdds);
Garfish 履行 registerApp
函数 结束后触发 registerApp
生命周期hook, 将当时注册的子运用列表发送到工作回调
garfish/packages/router/src/index.ts
{
name: 'router',
registerApp(appInfos) {
const appList = Object.values(appInfos);
router.registerRouter(appList.filter((app) => !!app.activeWhen));
// After completion of the registration application, trigger application mount
// Has been running after adding routing to trigger the redirection
if (!Garfish.running) return;
routerLog('registerApp initRedirect', appInfos);
initRedirect();
},
}
插件接收到子运用列表, 将顺次调用:
-
router.registerRouter
注册到路由列表,其间会把不存在activeWhen
特点的子运用过滤 -
initRedirect
初始化重定向逻辑
garfish/packages/router/src/context.ts
export const RouterConfig: Options = {
basename: '/',
current: {
fullPath: '/',
path: '/',
matched: [],
query: {},
state: {},
},
apps: [],
beforeEach: (to, from, next) => next(),
afterEach: (to, from, next) => next(),
active: () => Promise.resolve(),
deactive: () => Promise.resolve(),
routerChange: () => {},
autoRefreshApp: true,
listening: true,
};
export const registerRouter = (Apps: Array<interfaces.AppInfo>) => {
const unregisterApps = Apps.filter(
(app) => !RouterConfig.apps.some((item) => app.name === item.name),
);
RouterConfig[apps] = RouterConfig.apps.concat(unregisterApps);
};
const Router: RouterInterface = {
registerRouter,
};
export default Router;
在registerRouter
阶段仅仅是将子运用注册
export const initRedirect = () => {
linkTo({
toRouterInfo: {
fullPath: location.pathname,
path: getPath(RouterConfig.basename!),
query: parseQuery(location.search),
state: history.state,
},
fromRouterInfo: {
fullPath: '/',
path: '/',
query: {},
state: {},
},
eventType: 'pushState',
});
};
在initRedirect
阶段则是调用linkTo
函数去完成一个跳转,这里具体细节比较复杂。可以简略理解为子运用版页面跳转
// 重载指定路由
// 1. 当时的子运用需求毁掉
// 2. 获取当时需求激活的运用
// 3. 获取新的需求激活运用
// 4. 触发函数beforeEach,在毁掉一切运用之前触发
// 5. 触发需求毁掉运用的deactive函数
// 6. 假如不需求激活运用,默许触发popstate运用组件view child更新
export const linkTo = async ({
toRouterInfo,
fromRouterInfo,
eventType,
}: {
toRouterInfo: RouterInfo;
fromRouterInfo: RouterInfo;
eventType: keyof History | 'popstate';
}) => Promise<void>
bootstrap 阶段
this.hooks.lifecycle.bootstrap.emit(this.options);
{
name: 'router',
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;
});
if (needToDeleteApps.length > 0) {
needToDeleteApps.forEach((app) => {
delete Garfish.appInfos[app.name];
delete Garfish.cacheApps[app.name];
});
router.setRouterConfig({
apps: router.routerConfig.apps.filter((app) => {
return !needToDeleteApps.some(
(needDelete) => app.name === needDelete.name,
);
}),
});
}
}
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);
},
}
bootstrap
阶段首要构造路由装备,并调用listenRouterAndReDirect(listenOptions)
来进行路由的代理/阻拦
其间首要需求关心的active
操作(即子运用挂载逻辑)做了以下工作:
- 调用
Garfish.loadApp
将子运用挂载到子运用挂载节点上(Promise 同步加载) - 在
Garfish.apps
记载该app - 注册到 unmounts 记载毁掉逻辑
/**
* 1.注册子运用
* 2.对应子运用激活,触发激活回调
* @param Options
*/
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 registerRouter = (Apps: Array<interfaces.AppInfo>) => {
const unregisterApps = Apps.filter(
(app) => !RouterConfig.apps.some((item) => app.name === item.name),
);
RouterSet('apps', RouterConfig.apps.concat(unregisterApps));
};
registerRouter
没有什么特别的,仅仅办理路由状况
接下来看一下listen()
函数做的工作:
export const listen = () => {
normalAgent();
initRedirect();
};
initRedirect
咱们之前看过了,现在咱们首要看normalAgent
的完成
garfish/packages/router/src/agentRouter.ts
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,
query: parseQuery(location.search),
path: getPath(RouterConfig.basename!, urlAfter),
state: stateAfter,
},
fromRouterInfo: {
fullPath: urlBefore,
query: parseQuery(location.search),
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,
query: parseQuery(location.search),
path: getPath(RouterConfig.basename!),
},
fromRouterInfo: {
fullPath: RouterConfig.current!.fullPath,
path: getPath(
RouterConfig.basename!,
RouterConfig.current!.path,
),
query: RouterConfig.current!.query,
},
eventType: 'popstate',
},
}),
);
},
false,
);
window[__GARFISH_ROUTER_FLAG__] = true;
}
addRouterListener();
};
normalAgent
做了以下工作:
- 经过
rewrite
函数重写history.pushState
和history.pushState
-
rewrite
函数则是在调用以上办法的前后增加了一些当时情况的快照,假如url
/state
发生变化则触发__GARFISH_BEFORE_ROUTER_EVENT__
工作
-
- 对
popstate
工作增加监听 - 调用
addRouterListener
增加路由监听回调。监听办法依据浏览器内置的工作体系,工作名:__GARFISH_BEFORE_ROUTER_EVENT__
综上, router
经过监听history
的办法来履行副作用调用linkTo
函数,而linkTo
函数则经过一系列操作将匹配的路由调用active
办法,将不匹配的路由调用deactive
办法以完成类型切换
这时候咱们再回过头来看一下active
函数的完成
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);
}
}
}
其间心代码则是调用了Garfish.loadApp
办法来履行加载操作。
运用加载
接下来咱们看一下loadApp
函数
garfish/packages/core/src/garfish.ts
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, options);
assert(
appInfo.entry,
`Can't load unexpected child app "https://juejin.im/post/7123214198699474951/${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];
}
该函数做了以下操作:
- 首要履行
asyncLoadProcess
来异步加载app,假如app正在加载则回来该Promise - 运用
generateAppOptions
计算大局+本地的装备,并经过黑名单过滤掉一部分的无用参数(filterAppConfigKeys) - 假如当时app已加载则直接回来缓存后的内容
- 假如是第一次加载,则履行
processAppResources
进行恳求, 恳求的地址为entry
指定的地址。 - 当恳求结束后创立
new App
目标,将其放到内存中 - 运用插件/记载缓存/发布生命周期工作等
接下来咱们看中心函数, processAppResources
的完成
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 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 JavaScriptManager) {
// Js entry
isHtmlMode = false;
const mockTemplateCode = `<script src="https://juejin.im/post/7123214198699474951/${entryManager.url}"></script>`;
fakeEntryManager = new TemplateManager(mockTemplateCode, entryManager.url);
entryManager.setDep(fakeEntryManager.findAllJsNodes()[0]);
resources.js = [entryManager];
} else {
error(`Entrance wrong type of resource of "https://juejin.im/post/7123214198699474951/${appInfo.name}".`);
}
return [fakeEntryManager || entryManager, resources, isHtmlMode];
}
首要依据appInfo.entry
调用loader.load
函数,生成一个entryManager
。假如entry指向的是html地址则获取静态数据后拿取js,link,modules
,假如entry指向的是一个js地址则伪造一个仅包括这段js的js资源。最后的回来值是一个 [resourceManager, resources, isHtmlMode]
的元组。
其间resourceManager
的大约结构如下:
loader.load
的本质上便是发恳求获取数据然后把恳求到的纯文本转化成结构化,假如是html则对html声明的资源进行进一步的恳求获取。这边就不再赘述。
咱们回到loadApp
函数的完成。
之后,代码依据processAppResources
获取到的[resourceManager, resources, isHtmlMode]
信息来创立一个new App
;
appInstance = new App(
this,
appInfo,
manager,
resources,
isHtmlMode,
appInfo.customLoader,
);
new App
的进程中没有任何逻辑,仅仅是一些变量的界说。值得留意的是在此进程中会对插件体系做一些初始化设定
garfish/packages/core/src/module/app.ts
export class App {
constructor(
context: Garfish,
appInfo: AppInfo,
entryManager: TemplateManager,
resources: interfaces.ResourceModules,
isHtmlMode: boolean,
customLoader?: CustomerLoader,
) {
// ...
// Register hooks
this.hooks = appLifecycle();
this.hooks.usePlugin({
...appInfo,
name: `${appInfo.name}-lifecycle`,
});
// ...
}
}
到这一步为止,咱们还在做一些准备工作:
- 从远程获取资源
- 将纯文本解析成结构化目标和AST
- 进一步获取js/css的实践代码
接下来咱们需求一个调用方可以帮助咱们将获取到的资源履行并挂载到dom上。
这时候咱们就需求回到咱们的router
插件。还记得咱们的GarfishRouter.bootstrap.active
里的代码么?
garfish/packages/router/src/index.ts
export function GarfishRouter(_args?: Options) {
return function (Garfish: interfaces.Garfish): interfaces.Plugin {
return {
// ...
bootstrap(options: interfaces.Options) {
// ...
async function active(
appInfo: interfaces.AppInfo,
rootPath: string = '/',
) {
// ...
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);
}
}
}
// ...
};
};
}
当咱们第一次履行到call
函数时,会履行app.mount()
函数来完成运用的挂载。
咱们看下app.mount()
的完成:
garfish/packages/core/src/module/app.ts
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);
// add container and compile js with cjs
const { asyncScripts } = await this.compileAndRenderContainer();
if (!this.stopMountAndClearEffect()) return false;
// 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);
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;
}
// Performs js resources provided by the module, finally get the content of the export
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();
// Execute asynchronous script
return {
asyncScripts: new Promise<void>((resolve) => {
// Asynchronous script does not block the rendering process
setTimeout(() => {
if (this.stopMountAndClearEffect()) {
for (const jsManager of this.resources.js) {
if (jsManager.async) {
try {
this.execScript(
jsManager.scriptCode,
{},
jsManager.url || this.appInfo.entry,
{
async: false,
noEntry: true,
},
);
} catch (e) {
this.hooks.lifecycle.errorMountApp.emit(e, this.appInfo);
}
}
}
}
resolve();
});
}),
};
}
}
mount
首要完成以下操作:
- 生命周期的分发:
beforeMount
,afterMount
- 状况变更:
this.active
,this.mounting
,this.display
- 调用
this.compileAndRenderContainer
履行编译- 调用
this.renderTemplate
渲染同步代码片段 - 回来
asyncScripts
函数用于鄙人一个宏任务(task) 履行异步js代码片段
- 调用
- 在每一个异步片段进程中都尝试履行
stopMountAndClearEffect
来判断当时状况,以保证状况的准确性(用于处理在异步代码履行进程中被撤销的问题)
咱们看一下renderTemplate
的逻辑:
export class App {
private async renderTemplate() {
const { appInfo, entryManager, resources } = this;
const { url: baseUrl, DOMApis } = entryManager;
const { htmlNode, appContainer } = createAppContainer(appInfo);
// 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] = {
// ...
body: (node) => {
if (!this.strictIsolation) {
node = entryManager.cloneNode(node);
node.tagName = 'div';
node.attributes.push({
key: __MockBody__,
value: null,
});
}
return DOMApis.createElement(node);
},
script: (node) => {
const mimeType = entryManager.findAttributeValue(node, 'type');
const isModule = mimeType === 'module';
if (mimeType) {
// Other script template
if (!isModule && !isJsType({ type: mimeType })) {
return DOMApis.createElement(node);
}
}
const jsManager = resources.js.find((manager) => {
return !manager.async ? manager.isSameOrigin(node) : false;
});
if (jsManager) {
const { url, scriptCode } = jsManager;
this.execScript(scriptCode, {}, url || this.appInfo.entry, {
isModule,
async: false,
isInline: jsManager.isInlineScript(),
noEntry: toBoolean(
entryManager.findAttributeValue(node, 'no-entry'),
),
});
} else if (__DEV__) {
const async = entryManager.findAttributeValue(node, 'async');
if (typeof async === 'undefined' || async === 'false') {
const tipInfo = JSON.stringify(node, null, 2);
warn(
`Current js node cannot be found, the resource may not exist.\n\n ${tipInfo}`,
);
}
}
return DOMApis.createScriptCommentNode(node);
},
// ...
};
// Render dom tree and append to document.
entryManager.createElements(customRenderer, htmlNode);
}
}
- 调用
createAppContainer
函数创立一些空白的容器dom, 留意此时还没有挂载到界面上:export function createAppContainer(appInfo: interfaces.AppInfo) { const name = appInfo.name; // 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}_${name}_${createKey()}`; return { htmlNode, appContainer, }; }
- 假如开启了
sandbox
和strictIsolation
装备则进行严厉的阻隔(运用appContainer.attachShadow
)来创立ShadowDOM
- 假如开启了
- 调用
addContainer
来将代码挂载容器组件到文档中, 经过履行domGetter
来获取父容器节点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); } }
- 调用
entryManager.createElements(customRenderer, htmlNode);
来实践创立节点。
运用// Render dom tree createElements(renderer: Renderer, parent: Element) { const elements: Array<Element> = []; const traverse = (node: Node | Text, parentEl?: Element) => { let el: any; if (this.DOMApis.isCommentNode(node)) { // Filter comment node } else if (this.DOMApis.isText(node)) { el = this.DOMApis.createTextNode(node); parentEl && parentEl.appendChild(el); } else if (this.DOMApis.isNode(node)) { const { tagName, children } = node as Node; if (renderer[tagName]) { el = renderer[tagName](node as Node); } else { el = this.DOMApis.createElement(node as Node); } if (parentEl && el) parentEl.appendChild(el); if (el) { const { nodeType, _ignoreChildNodes } = el; // Filter "comment" and "document" node if (!_ignoreChildNodes && nodeType !== 8 && nodeType !== 10) { for (const child of children) { traverse(child, el); } } } } return el; }; for (const node of this.astTree) { if (this.DOMApis.isNode(node) && node.tagName !== '!doctype') { const el = traverse(node, parent); el && elements.push(el); } } return elements; }
traverse
函数对本身进行树节点遍历,将ast树转换为dom树并挂载到parent
上- 留意有意思的一点是他是在遍历
ast
进程中的一起履行appendChild
办法加载到dom树上而不是将节点生成结束后一次性加载(也许是由于操作都是在一个task中所以浏览器会一次性履行?)
- 留意有意思的一点是他是在遍历
总结
综上,garfish
完成了一次远程获取目标代码 => 解析成ast => 然后再从ast转换成dom树的进程。
将一段远程的页面/js加载到当时页面的固定位置