背景 Garfish
是字节跳动 web infra
团队推出的一款微前端框架
包含构建微前端系统时所需要的基本能力,任意前端框架均可使用。接入简单,可轻松将多个前端应用组合成内聚的单个产品
因为当前对 Garfish
的解读极少,而微前端又是现代前端领域相当重要的一环,因此写下本文,同时也是对学习源码的一个总结
本文基于 garfish#0d4cc0c82269bce8422b0e9105b7fe88c2efe42a 进行解读
学习源码 1 2 3 4 5 git clone https://github.com/modern-js-dev/garfish.git cd garfishpnpm install pnpm build pnpm dev
然后打开https://localhost:8090/
即可看到演示项目
基本使用 主应用 1 2 3 4 5 6 7 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为例:
1 2 3 4 5 6 7 8 9 10 import { reactBridge, AppInfo } from '@garfish/bridge-react' ;export const provider = reactBridge({ el: '#root' , 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 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' )); this .hooks.lifecycle.beforeBootstrap.emit(this .options); this .registerApp(this .options.apps || []); this .running = true ; this .hooks.lifecycle.bootstrap.emit(this .options); return this ; }
其中移除插件等内容,最重要的是registerApp
调用,用于将配置注册到实例中
接下来的代码会移除无关紧要的代码,仅保留核心逻辑
1 2 3 4 5 6 7 8 9 10 11 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 ; }
看上去仅仅是一些配置设定,那么所谓的路由绑定是从哪里发生的呢?这一切其实早就暗中进行了处理。
1 2 3 4 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实例。该逻辑简化版大概如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 import { GarfishRouter } from '@garfish/router' ;import { GarfishBrowserVm } from '@garfish/browser-vm' ;import { GarfishBrowserSnapshot } from '@garfish/browser-snapshot' ;function createContext ( ): Garfish { 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 ) => { window [namespace ] = val; }; if (inBrowser()) { 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 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, }); 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] = () => { 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]; 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); router.registerRouter(appList.filter((app ) => !!app.activeWhen)); 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 阶段 1 this .hooks.lifecycle.registerApp.emit(currentAdds);
Garfish 执行 registerApp
函数 完毕后触发 registerApp
生命周期hook, 将当前注册的子应用列表发送到事件回调
garfish/packages/router/src/index.ts
1 2 3 4 5 6 7 8 9 10 11 12 { name: 'router' , registerApp (appInfos ) { const appList = Object .values(appInfos); router.registerRouter(appList.filter((app ) => !!app.activeWhen)); if (!Garfish.running) return ; routerLog('registerApp initRedirect' , appInfos); initRedirect(); }, }
插件接收到子应用列表, 将依次调用:
router.registerRouter
注册到路由列表,其中会把不存在activeWhen
属性的子应用过滤
initRedirect
初始化重定向逻辑
garfish/packages/router/src/context.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 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
阶段仅仅是将子应用注册
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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 5 6 7 8 9 10 11 12 13 14 15 16 export const linkTo = async ({ toRouterInfo, fromRouterInfo, eventType, }: { toRouterInfo: RouterInfo; fromRouterInfo: RouterInfo; eventType: keyof History | 'popstate' ; }) => Promise <void >
bootstrap 阶段 1 this .hooks.lifecycle.bootstrap.emit(this .options);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 { 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, }); 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] = () => { 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]; 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 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 export const listenRouterAndReDirect = ({ apps, basename = '/' , autoRefreshApp, active, deactive, notMatch, listening = true , }: Options) => { registerRouter(apps); setRouterConfig({ basename, autoRefreshApp, active, deactive, notMatch, listening, }); listen(); };
1 2 3 4 5 6 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()
函数做的事情:
1 2 3 4 export const listen = () => { normalAgent(); initRedirect(); };
initRedirect
我们之前看过了,现在我们主要看normalAgent
的实现
garfish/packages/router/src/agentRouter.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 export const normalAgent = () => { 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__]) { 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 , }, }), ); } return res; }; }; history.pushState = rewrite('pushState' ); history.replaceState = rewrite('replaceState' ); window .addEventListener( 'popstate' , function (event ) { 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
函数的实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 async function active ( appInfo: interfaces.AppInfo, rootPath: string = '/' , ) { routerLog(`${appInfo.name} active` , { appInfo, rootPath, listening: RouterConfig.listening, }); 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] = () => { 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 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 () => { const stop = await this .hooks.lifecycle.beforeLoad.emit(appInfo); if (stop === false ) { warn(`Load ${appName} application is terminated by beforeLoad.` ); return null ; } appInfo = generateAppOptions(appName, this , options); assert( appInfo.entry, `Can't load unexpected child app "${appName} ", ` + 'Please provide the entry parameters or registered in advance of the app.' , ); 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, ); 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
的实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 export async function processAppResources (loader: Loader, appInfo: AppInfo ) { let isHtmlMode: Boolean = false , fakeEntryManager; const resources: any = { js : [], link : [], modules : [] }; assert(appInfo.entry, `[${appInfo.name} ] Entry is not specified.` ); const { resourceManager : entryManager } = await loader.load({ scope: appInfo.name, url: transformUrl(location.href, appInfo.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) { isHtmlMode = false ; const mockTemplateCode = `<script src="${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 "${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
;
1 2 3 4 5 6 7 8 appInstance = new App( this , appInfo, manager, resources, isHtmlMode, appInfo.customLoader, );
new App
的过程中没有任何逻辑,仅仅是一些变量的定义。值得注意的是在此过程中会对插件系统做一些初始化设定
garfish/packages/core/src/module/app.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 export class App { constructor ( context: Garfish, appInfo: AppInfo, entryManager: TemplateManager, resources: interfaces.ResourceModules, isHtmlMode: boolean , customLoader?: CustomerLoader, ) { 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 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] = () => { 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 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 ); const { asyncScripts } = await this .compileAndRenderContainer(); if (!this .stopMountAndClearEffect()) return false ; const provider = await this .getProvider(); 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 ; } async compileAndRenderContainer ( ) { await this .renderTemplate(); return { asyncScripts: new Promise <void >((resolve ) => { 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
的逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 export class App { private async renderTemplate ( ) { const { appInfo, entryManager, resources } = this ; const { url : baseUrl, DOMApis } = entryManager; const { htmlNode, appContainer } = createAppContainer(appInfo); this .htmlNode = htmlNode; this .appContainer = appContainer; 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) { 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); }, }; entryManager.createElements(customRenderer, htmlNode); } }
调用 createAppContainer
函数创建一些空白的容器dom, 注意此时还没有挂载到界面上:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 export function createAppContainer (appInfo: interfaces.AppInfo ) { const name = appInfo.name; 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); dispatchEvents(root); } else { htmlNode.setAttribute(__MockHtml__, '' ); appContainer.appendChild(htmlNode); } appContainer.id = `${appContainerId} _${name} _${createKey()} ` ; return { htmlNode, appContainer, }; }
如果开启了 sandbox
和 strictIsolation
配置则进行严格的隔离(使用appContainer.attachShadow
)来创建ShadowDOM
调用addContainer
来将代码挂载容器组件到文档中, 通过执行domGetter
来获取父容器节点1 2 3 4 5 6 7 private async addContainer ( ) { const wrapperNode = await getRenderNode(this .appInfo.domGetter); if (typeof wrapperNode.appendChild === 'function' ) { wrapperNode.appendChild(this .appContainer); } }
调用entryManager.createElements(customRenderer, htmlNode);
来实际创建节点。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 createElements (renderer: Renderer, parent: Element ) { const elements: Array <Element> = []; const traverse = (node: Node | Text, parentEl?: Element ) => { let el: any ; if (this .DOMApis.isCommentNode(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; 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加载到当前页面的固定位置