布景

Garfish 是字节跳动 web infra 团队推出的一款微前端框架

包括构建微前端体系时所需求的根本能力,任意前端框架均可运用。接入简略,可轻松将多个前端运用组合成内聚的单个产品

Garfish 源码解析 —— 一个微应用是如何被挂载的

由于当时对 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的通用生命周期,包括renderdestroy

源码解读

那么简略了解了一些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 插件为例,其作用在bootstrapregisterApp两个生命周期阶段

生命周期界说可以在这里看到: 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.pushStatehistory.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的大约结构如下:

Garfish 源码解析 —— 一个微应用是如何被挂载的

loader.load的本质上便是发恳求获取数据然后把恳求到的纯文本转化成结构化,假如是html则对html声明的资源进行进一步的恳求获取。这边就不再赘述。

咱们回到loadApp函数的完成。

之后,代码依据processAppResources获取到的[resourceManager, resources, isHtmlMode]信息来创立一个new App;

appInstance = new App(
  this,
  appInfo,
  manager,
  resources,
  isHtmlMode,
  appInfo.customLoader,
);

Garfish 源码解析 —— 一个微应用是如何被挂载的

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,
      };
    }
    
    • 假如开启了 sandboxstrictIsolation 装备则进行严厉的阻隔(运用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加载到当时页面的固定位置