Garfish源码解析:子应用加载流程

Garfish简介

Garfish是字节跳动开源的一款微前端框架,支持将单个产品拆分为一个主应用和若干个子应用,主应用和各个子应用能够分别进行开发、测试、部署。与之类似的框架有qiankun、MicroApp、无界等。Garfish的使用方式为:

  • 主应用在入口处调用Garfish.run
js 复制代码
import Garfish from 'garfish';

Garfish.run({
  basename: '/',
  domGetter: '#subApp',
  apps: [
    {
      name: 'react',
      activeWhen: '/react',
      entry: 'http://localhost:3000', // html入口
    },
    {
      name: 'vue',
      activeWhen: '/vue',
      entry: 'http://localhost:8080/index.js', // js入口
    },
  ],
});
  • 子应用在入口处导出一个provider函数
js 复制代码
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Switch, Route, Link } from 'react-router-dom';

export const provider = () => ({
  // render 渲染函数,必须提供
  render: ({ dom, basename }) => {
    // 和子应用独立运行时一样,将子应用渲染至对应的容器节点,根据不同的框架使用不同的渲染方式
    ReactDOM.render(
      <React.StrictMode>
        <App basename={basename} />
      </React.StrictMode>,
      // 需要注意的一点是,子应用的入口是否为 HTML 类型(即在主应用的中配置子应用的 entry 地址为子应用的 html 地址),
      // 如果为 HTML 类型,需要在 dom 的基础上选中子应用的渲染节点
      // 如果为 JS 类型,则直接将 dom 作为渲染节点即可
      dom.querySelector('#root'),
    );
  },
  // destroy 应用销毁函数,必须提供
  destroy: ({ dom, basename }) => {
    // 使用框架提供的销毁函数销毁整个应用,已达到销毁框架中可能存在得副作用,并触发应用中的一些组件销毁函数
    // 需要注意的时一定要保证对应框架得销毁函数使用正确,否则可能导致子应用未正常卸载影响其他子应用
    ReactDOM.unmountComponentAtNode(
      dom ? dom.querySelector('#root') : document.querySelector('#root'),
    );
  },
});

从主应用导入Garfish、调用Garfish.run,到子应用加载并显示,其大致流程如下所示

主应用启动Garfish

Garfish采用了单例模式,主应用通过import Garfish from 'garfish';获取的是一个单例对象

js 复制代码
// Initialize the Garfish, currently existing environment to allow only one instance (export to is for test)
function createContext(): Garfish {
  let fresh = false;
  // Existing garfish instance, direct return
  if (inBrowser() && window['__GARFISH__'] && window['Garfish']) {
    return window['Garfish'];
  }

  const GarfishInstance = new Garfish({
    plugins: [GarfishRouter(), GarfishBrowserVm(), GarfishBrowserSnapshot()],
  });

  type globalValue = boolean | Garfish | Record<string, unknown>;
  const set = (namespace: string, val: globalValue = GarfishInstance) => {
    if (hasOwn(window, namespace)) {
      if (!(window[namespace] && window[namespace].flag === __GARFISH_FLAG__)) {
        const next = () => {
          fresh = true;
          if (__DEV__) {
            warn(`"Window.${namespace}" will be overwritten by "garfish".`);
          }
        };
        const desc = Object.getOwnPropertyDescriptor(window, namespace);
        if (desc) {
          if (desc.configurable) {
            def(window, namespace, val);
            next();
          } else if (desc.writable) {
            window[namespace] = val;
            next();
          }
        }
      }
    } else {
      fresh = true;
      def(window, namespace, val);
    }
  };

  if (inBrowser()) {
    // Global flag
    set('Garfish');
    def(window, '__GARFISH__', true);
  }

  if (fresh) {
    if (__DEV__) {
      if (__VERSION__ !== window['Garfish'].version) {
        warn(
          'The "garfish version" used by the main and sub-applications is inconsistent.',
        );
      }
    }
  }
  return GarfishInstance;
}

createContext在创建Garfish对象之后,将其挂在了window上,后续直接通过window['Garfish']获取。

导入Garfish之后,主应用通过Garfish.run来进行启动

js 复制代码
export class Garfish extends EventEmitter2 {
  ...
  run(options: interfaces.Options = {}) {
    if (this.running) {
      if (__DEV__) {
        warn('Garfish is already running now, Cannot run Garfish repeatedly.');
      }
      return this;
    }

    this.setOptions(options);
    // Register plugins
    options.plugins?.forEach((plugin) => this.usePlugin(plugin));
    // Put the lifecycle plugin at the end, so that you can get the changes of other plugins
    this.usePlugin(GarfishOptionsLife(this.options, 'global-lifecycle'));

    // Emit hooks and register apps
    this.hooks.lifecycle.beforeBootstrap.emit(this.options);
    this.registerApp(this.options.apps || []);
    this.running = true;
    this.hooks.lifecycle.bootstrap.emit(this.options);
    return this;
  }
  ...
}

在run方法中主要进行了选项设置、插件注册、子应用注册、钩子触发。

bootstrap钩子触发路由插件

Garfish插件系统

Garfish设计了一套基于发布订阅模式的插件系统。以Garfish类为例,在Garfish类中定义了一套钩子(hooks),其中的每一个钩子都是一个发布者,会在不同的时机进行发布(emit)。

js 复制代码
export function globalLifecycle() {
  return new PluginSystem({
    beforeBootstrap: new SyncHook<[interfaces.Options], void>(),
    bootstrap: new SyncHook<[interfaces.Options], void>(),
    beforeRegisterApp: new SyncHook<[interfaces.AppInfo | Array<interfaces.AppInfo>], void>(),
    registerApp: new SyncHook<[Record<string, interfaces.AppInfo>], void>(),
    beforeLoad: new AsyncHook<[interfaces.AppInfo]>(),
    afterLoad: new AsyncHook<[interfaces.AppInfo, interfaces.App | null]>(),
    errorLoadApp: new SyncHook<[Error, interfaces.AppInfo], void>(),
  });
}

export class Garfish extends EventEmitter2 {
  ...
  public hooks = globalLifecycle();
  ...
  run(options: interfaces.Options = {}) {
    if (this.running) {
      if (__DEV__) {
        warn('Garfish is already running now, Cannot run Garfish repeatedly.');
      }
      return this;
    }

    this.setOptions(options);
 
    options.plugins?.forEach((plugin) => this.usePlugin(plugin));
    
    this.usePlugin(GarfishOptionsLife(this.options, 'global-lifecycle'));

    // 触发beforeBootstrap钩子
    this.hooks.lifecycle.beforeBootstrap.emit(this.options);
    this.registerApp(this.options.apps || []);
    this.running = true;
    // 触发bootstrap钩子
    this.hooks.lifecycle.bootstrap.emit(this.options);
    return this;
  }
  ...
}

而插件(plugin)则可以通过定义与特定钩子同名的函数来订阅相应的钩子,当钩子触发(emit)时就会执行该函数。以路由插件GarfishRouter为例,该插件订阅了bootstrap和registerApp两个钩子:

js 复制代码
export function GarfishRouter(_args?: Options) {
  return function (Garfish: interfaces.Garfish): interfaces.Plugin {
    Garfish.apps = {};
    Garfish.router = router;

    return {
      name: 'router',
      version: __VERSION__,

      bootstrap(options: interfaces.Options) {
        ...
      },

      registerApp(appInfos) {
        ...
      },
    };
  };
}

Garfish单例在创建时会注册一些内部插件,如路由插件GarfishRouter、vm沙箱插件GarfishBrowserVm、快照沙箱插件GarfishBrowserSnapshot等等。Garfish也支持用户注册自定义插件。

路由插件GarfishRouter

在Garfish.run的最后阶段会触发bootstrap钩子,路由插件GarfishRouter中定义的bootstrap函数会在此时被执行

js 复制代码
bootstrap(options: interfaces.Options) {
  let activeApp: null | string = null;
  const unmounts: Record<string, Function> = {};
  const { basename } = options;
  const { autoRefreshApp = true, onNotMatchRouter = () => null } =
    Garfish.options;

  async function active(
    appInfo: interfaces.AppInfo,
    rootPath: string = '/',
  ) {
    ...
  }

  async function deactive(appInfo: interfaces.AppInfo, rootPath: string) {
    ...
  }

  const apps = Object.values(Garfish.appInfos);

  const appList = apps.filter((app) => {
    if (!app.basename) app.basename = basename;
    return !!app.activeWhen;
  }) as Array<Required<interfaces.AppInfo>>;

  const listenOptions = {
    basename,
    active,
    deactive,
    autoRefreshApp,
    notMatch: onNotMatchRouter,
    apps: appList,
    listening: true,
  };
  routerLog('listenRouterAndReDirect', listenOptions);
  listenRouterAndReDirect(listenOptions);
}

export const listenRouterAndReDirect = ({
  apps,
  basename = '/',
  autoRefreshApp,
  active,
  deactive,
  notMatch,
  listening = true,
}: Options) => {
  // 注册子应用、注册激活、销毁钩子
  registerRouter(apps);

  // 初始化信息
  setRouterConfig({
    basename,
    autoRefreshApp,
    // supportProxy: !!window.Proxy,
    active,
    deactive,
    notMatch,
    listening,
  });

  // 开始监听路由变化触发、子应用更新。重载默认初始子应用
  listen();
};

export const listen = () => {
  // 劫持pushState、replaceState、popstate事件
  normalAgent();
  // 加载初始URL对应的子应用
  initRedirect();
};

在路由插件中,有一个全局对象RouterConfig,其中记录了basename、当前URL路径及子应用、注册的所有子应用等信息。在listenRouterAndReDirect中,setRouterConfig用于设置该对象。listen则有两个作用:

  1. 通过normalAgent劫持pushState、replaceState和popstate事件
  2. 通过initRedirect加载初始URL对应的子应用
js 复制代码
export const normalAgent = () => {
  // By identifying whether have finished listening, if finished listening, listening to the routing changes do not need to hijack the original event
  // Support nested scene
  const addRouterListener = function () {
    window.addEventListener(__GARFISH_BEFORE_ROUTER_EVENT__, function (env) {
      RouterConfig.routerChange && RouterConfig.routerChange(location.pathname);
      linkTo((env as any).detail);
    });
  };

  if (!window[__GARFISH_ROUTER_FLAG__]) {
    // Listen for pushState and replaceState, call linkTo, processing, listen back
    // Rewrite the history API method, triggering events in the call

    const rewrite = function (type: keyof History) {
      const hapi = history[type];
      return function (this: History) {
        const urlBefore = window.location.pathname + window.location.hash;
        const stateBefore = history?.state;
        const res = hapi.apply(this, arguments);
        const urlAfter = window.location.pathname + window.location.hash;
        const stateAfter = history?.state;

        const e = createEvent(type);
        (e as any).arguments = arguments;

        if (
          urlBefore !== urlAfter ||
          JSON.stringify(stateBefore) !== JSON.stringify(stateAfter)
        ) {
          window.dispatchEvent(
            new CustomEvent(__GARFISH_BEFORE_ROUTER_EVENT__, {
              detail: {
                toRouterInfo: {
                  fullPath: urlAfter,
                  href: location.href,
                  query: parseQuery(location.search),
                  path: getPath(RouterConfig.basename!, urlAfter),
                  state: stateAfter,
                },
                fromRouterInfo: {
                  fullPath: urlBefore,
                  query: RouterConfig.current!.query,
                  href: RouterConfig.current!.href,
                  path: getPath(RouterConfig.basename!, urlBefore),
                  state: stateBefore,
                },
                eventType: type,
              },
            }),
          );
        }
        // window.dispatchEvent(e);
        return res;
      };
    };

    history.pushState = rewrite('pushState');
    history.replaceState = rewrite('replaceState');

    // Before the collection application sub routing, forward backward routing updates between child application
    window.addEventListener(
      'popstate',
      function (event) {
        // Stop trigger collection function, fire again match rendering
        if (event && typeof event === 'object' && (event as any).garfish)
          return;
        if (history.state && typeof history.state === 'object')
          delete history.state[__GARFISH_ROUTER_UPDATE_FLAG__];
        window.dispatchEvent(
          new CustomEvent(__GARFISH_BEFORE_ROUTER_EVENT__, {
            detail: {
              toRouterInfo: {
                fullPath: location.pathname,
                href: location.href,
                query: parseQuery(location.search),
                path: getPath(RouterConfig.basename!),
              },
              fromRouterInfo: {
                fullPath: RouterConfig.current!.fullPath,
                path: getPath(
                  RouterConfig.basename!,
                  RouterConfig.current!.path,
                ),
                href: RouterConfig.current!.href,
                query: RouterConfig.current!.query,
              },
              eventType: 'popstate',
            },
          }),
        );
      },
      false,
    );

    window[__GARFISH_ROUTER_FLAG__] = true;
  }
  addRouterListener();
};

export const initRedirect = () => {
  linkTo({
    toRouterInfo: {
      fullPath: location.pathname,
      href: location.href,
      path: getPath(RouterConfig.basename!),
      query: parseQuery(location.search),
      state: history.state,
    },
    fromRouterInfo: {
      fullPath: '/',
      href: '',
      path: '/',
      query: {},
      state: {},
    },
    eventType: 'pushState',
  });
};

normalAgent劫持浏览器的路由变化,并触发自定义事件__GARFISH_BEFORE_ROUTER_EVENT__,在事件中记录了路由变化前后的URL信息以及触发路由变化的原因(pushState、replaceState、popstate事件)。通过监听该自定义事件,Garfish能够感知路由变化并通过linkTo来处理子应用的变更。

在初始化时,Garfish会在initRedirect中根据初始URL调用一次linkTo,以加载初始子应用。

js 复制代码
export const linkTo = async ({
  toRouterInfo,
  fromRouterInfo,
  eventType,
}: {
  toRouterInfo: RouterInfo;
  fromRouterInfo: RouterInfo;
  eventType: keyof History | 'popstate';
}) => {
  const {
    current,
    apps,
    deactive,
    active,
    notMatch,
    beforeEach,
    afterEach,
    autoRefreshApp,
  } = RouterConfig;

  const deactiveApps = current!.matched.filter(
    (appInfo) =>
      !hasActive(
        appInfo.activeWhen,
        getPath(appInfo.basename, location.pathname),
      ),
  );

  // Activate the corresponding application
  const activeApps = apps.filter((appInfo) => {
    return hasActive(
      appInfo.activeWhen,
      getPath(appInfo.basename, location.pathname),
    );
  });

  const needToActive = activeApps.filter(({ name }) => {
    return !current!.matched.some(({ name: cName }) => name === cName);
  });

  // router infos
  const to = {
    ...toRouterInfo,
    matched: needToActive,
  };

  const from = {
    ...fromRouterInfo,
    matched: deactiveApps,
  };

  await toMiddleWare(to, from, beforeEach!);

  // Pause the current application of active state
  if (current!.matched.length > 0) {
    await asyncForEach(
      deactiveApps,
      async (appInfo) =>
        await deactive(appInfo, getPath(appInfo.basename, location.pathname)),
    );
  }

  setRouterConfig({
    current: {
      path: getPath(RouterConfig.basename!),
      fullPath: location.pathname,
      href: location.href,
      matched: activeApps,
      state: history.state,
      query: parseQuery(location.search),
    },
  });

  // Within the application routing jump, by collecting the routing function for processing.
  // Filtering gar-router popstate hijacking of the router
  // In the switch back and forth in the application is provided through routing push method would trigger application updates
  // application will refresh when autoRefresh configuration to true
  const curState = window.history.state || {};
  if (
    eventType !== 'popstate' &&
    (curState[__GARFISH_ROUTER_UPDATE_FLAG__] || autoRefreshApp)
  ) {
    callCapturedEventListeners(eventType);
  }

  await asyncForEach(needToActive, async (appInfo) => {
    // Function using matches character and routing using string matching characters
    const appRootPath = getAppRootPath(appInfo);
    await active(appInfo, appRootPath);
  });

  if (activeApps.length === 0 && notMatch) notMatch(location.pathname);

  await toMiddleWare(to, from, afterEach!);
};

在linkTo中,会对需要激活的子应用调用active函数。active是定义在路由插件的bootstrap函数中的:

js 复制代码
bootstrap(options: interfaces.Options) {
  ...
  async function active(
    appInfo: interfaces.AppInfo,
    rootPath: string = '/',
  ) {
    routerLog(`${appInfo.name} active`, {
      appInfo,
      rootPath,
      listening: RouterConfig.listening,
    });

    // In the listening state, trigger the rendering of the application
    if (!RouterConfig.listening) return;

    const { name, active, cache = true } = appInfo;
    if (active) return active(appInfo, rootPath);
    appInfo.rootPath = rootPath;

    const currentApp = (activeApp = createKey());
    const app = await Garfish.loadApp(appInfo.name, {
      cache,
      basename: rootPath,
      entry: appInfo.entry,
      domGetter: appInfo.domGetter,
    });

    if (app) {
      app.appInfo.basename = rootPath;

      const call = async (app: interfaces.App, isRender: boolean) => {
        if (!app) return;
        const isDes = cache && app.mounted;
        if (isRender) {
          return await app[isDes ? 'show' : 'mount']();
        } else {
          return app[isDes ? 'hide' : 'unmount']();
        }
      };

      Garfish.apps[name] = app;
      unmounts[name] = () => {
        // Destroy the application during rendering and discard the application instance
        if (app.mounting) {
          delete Garfish.cacheApps[name];
        }
        call(app, false);
      };

      if (currentApp === activeApp) {
        await call(app, true);
      }
    }
  }
  ...
}

在active函数中,会通过Garfish.loadApp加载子应用资源、获取子应用的App实例,并触发子应用实例的mount生命周期。

Garfish.loadApp加载子应用资源

Garfish.loadApp的完整代码如下:

js 复制代码
export class Garfish extends EventEmitter2 {
  ...
  loadApp(
    appName: string,
    options?: Partial<Omit<interfaces.AppInfo, 'name'>>,
  ): Promise<interfaces.App | null> {
    assert(appName, 'Miss appName.');

    let appInfo = generateAppOptions(appName, this, options);

    const asyncLoadProcess = async () => {
      // Return not undefined type data directly to end loading
      const stop = await this.hooks.lifecycle.beforeLoad.emit(appInfo);

      if (stop === false) {
        warn(`Load ${appName} application is terminated by beforeLoad.`);
        return null;
      }

      //merge configs again after beforeLoad for the reason of app may be re-registered during beforeLoad resulting in an incorrect information
      appInfo = generateAppOptions(appName, this, appInfo);

      assert(
        appInfo.entry,
        `Can't load unexpected child app "${appName}", ` +
          'Please provide the entry parameters or registered in advance of the app.',
      );

      // Existing cache caching logic
      let appInstance: interfaces.App | null = null;
      const cacheApp = this.cacheApps[appName];

      if (appInfo.cache && cacheApp) {
        appInstance = cacheApp;
      } else {
        try {
          const [manager, resources, isHtmlMode] = await processAppResources(
            this.loader,
            appInfo,
          );

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

          // The registration hook will automatically remove the duplication
          for (const key in this.plugins) {
            appInstance.hooks.usePlugin(this.plugins[key]);
          }
          if (appInfo.cache) {
            this.cacheApps[appName] = appInstance;
          }
        } catch (e) {
          __DEV__ && warn(e);
          this.hooks.lifecycle.errorLoadApp.emit(e, appInfo);
        }
      }

      await this.hooks.lifecycle.afterLoad.emit(appInfo, appInstance);
      return appInstance;
    };

    if (!this.loading[appName]) {
      this.loading[appName] = asyncLoadProcess().finally(() => {
        delete this.loading[appName];
      });
    }
    return this.loading[appName];
  }
  ...
}

其主要作用有:

  • 合并全局配置和子应用配置,得到该子应用的最终配置
  • 判断是否可以使用缓存
  • 获取子应用entry资源以及所需的静态资源
  • 初始化子应用App实例、注册插件(App类也定义了一系列钩子),并返回该实例。

资源加载是通过processAppResources函数实现的

js 复制代码
export async function processAppResources(loader: Loader, appInfo: AppInfo) {
  let isHtmlMode: Boolean = false,
    fakeEntryManager;
  const resources: any = { js: [], link: [], modules: [] }; // Default resources
  assert(appInfo.entry, `[${appInfo.name}] Entry is not specified.`);
  const { resourceManager: entryManager } = await loader.load({
    scope: appInfo.name,
    url: transformUrl(location.href, appInfo.entry),
  });

  // Html entry
  if (entryManager instanceof loader.TemplateManager) {
    isHtmlMode = true;
    const [js, link, modules] = await fetchStaticResources(
      appInfo.name,
      loader,
      entryManager,
    );
    resources.js = js;
    resources.link = link;
    resources.modules = modules;
  } else if (entryManager instanceof loader.JavaScriptManager) {
    // Js entry
    isHtmlMode = false;
    const mockTemplateCode = `<script src="${entryManager.url}"></script>`;
    fakeEntryManager = new loader.TemplateManager(
      mockTemplateCode,
      entryManager.url,
    );
    entryManager.setDep(fakeEntryManager.findAllJsNodes()[0]);
    resources.js = [entryManager];
  } else {
    error(`Entrance wrong type of resource of "${appInfo.name}".`);
  }

  return [fakeEntryManager || entryManager, resources, isHtmlMode];
}

processAppResources会首先请求子应用entry对应的资源,并判断是HTML entry还是JS entry。如果是HTML entry,Garfish会根据返回的HTML生成AST,并请求其所需的所有静态资源(JS、CSS、remote module),以供后续使用。如果是JS entry,Garfish也会将其模拟成只有一个script标签的HTML,以便后续可以采用相同的方式处理。

子应用挂载(mount)

Garfish.loadApp完成之后,路由插件的bootstrap中定义的active函数会继续执行并调用子应用实例的mount方法,进行子应用的渲染。

js 复制代码
export class App {
  ...
  async mount() {
    if (!this.canMount()) return false;
    this.hooks.lifecycle.beforeMount.emit(this.appInfo, this, false);

    this.active = true;
    this.mounting = true;
    try {
      this.context.activeApps.push(this);
      // Because the 'unmount' lifecycle will reset 'customExports'
      // so we should initialize async registration while mounting
      this.initAsyncProviderRegistration();
      // add container and compile js with cjs
      const { asyncScripts, deferScripts } =
        await this.compileAndRenderContainer();
      if (!this.stopMountAndClearEffect()) return false;

      // The defer script is still a synchronous code and needs to be placed before `getProvider`
      deferScripts();

      // Good provider is set at compile time
      const provider = await this.getProvider();
      // Existing asynchronous functions need to decide whether the application has been unloaded
      if (!this.stopMountAndClearEffect()) return false;

      this.callRender(provider, true);
      this.display = true;
      this.mounted = true;
      this.hooks.lifecycle.afterMount.emit(this.appInfo, this, false);

      // Run async scripts
      await asyncScripts;
      if (!this.stopMountAndClearEffect()) return false;
    } catch (e) {
      this.entryManager.DOMApis.removeElement(this.appContainer);
      this.hooks.lifecycle.errorMountApp.emit(e, this.appInfo);
      return false;
    } finally {
      this.mounting = false;
    }
    return true;
  }
  ...
}

HTML模板渲染

子应用HTML模板的渲染是由compileAndRenderContainer中的renderTemplate完成的

js 复制代码
async compileAndRenderContainer() {
  // Render the application node
  // If you don't want to use the CJS export, at the entrance is not can not pass the module, the require
  await this.renderTemplate();

  const execScript = (type: 'async' | 'defer') => {
    ...
  };

  // Execute asynchronous script and defer script
  return {
    deferScripts: () => execScript('defer'),

    asyncScripts: new Promise<void>((resolve) => {
      // Asynchronous script does not block the rendering process
      setTimeout(() => {
        if (this.stopMountAndClearEffect()) {
          execScript('async');
        }
        resolve();
      });
    }),
  };
}

private async renderTemplate() {
  const { appInfo, entryManager, resources } = this;
  const { url: baseUrl, DOMApis } = entryManager;
  const { htmlNode, appContainer } = createAppContainer(appInfo);
  // The base url is fixed by default, so it is set when the sandbox is closed or the fix base url is not false
  const fixStaticResourceBaseUrl =
    !this.appInfo.sandbox ||
    (this.appInfo.sandbox &&
      this.appInfo.sandbox.fixStaticResourceBaseUrl === true);

  // Transformation relative path
  this.htmlNode = htmlNode;
  this.appContainer = appContainer;

  // To append to the document flow, recursive again create the contents of the HTML or execute the script
  await this.addContainer();

  const customRenderer: Parameters<typeof entryManager.createElements>[0] = {
    ...
  };

  // Render dom tree and append to document
  entryManager.createElements(customRenderer, htmlNode, (node, parent) => {
    // Trigger a custom render hook
    return this.hooks.lifecycle.customRender.emit({
      node,
      parent,
      app: this,
      customElement: null,
    });
  });
}

export function createAppContainer(appInfo: interfaces.AppInfo) {
  // Create a temporary node, which is destroyed by the module itself
  let htmlNode: HTMLDivElement | HTMLHtmlElement =
    document.createElement('div');
  const appContainer = document.createElement('div');

  if (appInfo.sandbox && appInfo.sandbox.strictIsolation) {
    htmlNode = document.createElement('html');
    const root = appContainer.attachShadow({ mode: 'open' });
    root.appendChild(htmlNode);
    // asyncNodeAttribute(htmlNode, document.body);
    dispatchEvents(root);
  } else {
    htmlNode.setAttribute(__MockHtml__, '');
    appContainer.appendChild(htmlNode);
  }

  appContainer.id = `${appContainerId}_${appInfo.name}_${createKey()}`;

  return {
    htmlNode,
    appContainer,
  };
}

private async addContainer() {
  // Initialize the mount point, support domGetter as promise, is advantageous for the compatibility
  const wrapperNode = await getRenderNode(this.appInfo.domGetter);
  if (typeof wrapperNode.appendChild === 'function') {
    wrapperNode.appendChild(this.appContainer);
  }
}

renderTemplate先通过createAppContainer创建htmlNode和appContainer两个DOM元素。这两个元素根据strictIsolation配置的不同有不同的实现:

  • 当strictIsolation为false时,appContainer和htmlNode都是div元素,appContainer是htmlNode的父节点,htmlNode用于模拟子应用的html节点。
  • 当strictIsolation为true时,子应用会放在shadow dom中,appContainer是div元素,htmlNode是放置在appContainer的shadow root中的html节点。

在创建htmlNode和appContainer之后,Garfish会通过子应用配置中的domGetter获取appContainer的挂载点并将其挂载上去。最后通过entryManager.createElements遍历AST,生成子应用DOM树,并挂载在htmlNode之下。

在遍历AST时,Garfish通过customRenderer对部分节点进行了特殊处理,其中比较关键的有body、head、link和script。

  • body、head:会判断strictIsolation,如果是true则生成body和head元素,否则用div来代替
  • link:如果其rel属性是stylesheet(代表CSS资源),则会生成style节点,并将Garfish.loadApp时请求到的CSS代码放入style节点中
  • script:脚本会区分为defer脚本、async脚本以及普通脚本。普通脚本会在遍历到对应script标签时就同步执行,而defer和async脚本会在子应用DOM树生成完成之后执行。由于脚本是Garfish自己在沙箱内执行的,因此脚本都会渲染成comment node。

获取子应用provider

在defer脚本执行完成之后,Garfish通过子应用实例的checkAndGetProvider获取子应用入口导出的provider函数

js 复制代码
export class App {
  ...
  private async checkAndGetProvider() {
    const { appInfo, rootElement, cjsModules, customExports } = this;
    const { name, props, basename } = appInfo;
    let provider:
      | ((...args: any[]) => interfaces.Provider)
      | interfaces.Provider
      | undefined = undefined;

    // esModule export
    await this.esmQueue.awaitCompletion();

    // Cjs exports
    if (cjsModules.exports) {
      if (isPromise(cjsModules.exports))
        cjsModules.exports = await cjsModules.exports;
      // Is not set in the configuration of webpack library option
      if (cjsModules.exports.provider) provider = cjsModules.exports.provider;
    }

    // Custom export prior to export by default
    if (customExports.provider) {
      provider = customExports.provider;
    }

    // async provider
    if (this.asyncProviderTimeout && !provider) {
      // this child app needs async provider registration
      provider = await this.awaitAsyncProviderRegistration();
    }

    if (typeof provider === 'function') {
      provider = await provider(
        {
          basename,
          dom: rootElement,
          ...(props || {}),
        },
        props,
      );
    } else if (isPromise(provider)) {
      provider = await provider;
    }

    // The provider may be a function object
    if (!isObject(provider) && typeof provider !== 'function') {
      warn(
        ` Invalid module content: ${name}, you should return both render and destroy functions in provider function.`,
      );
    }

    // If you have customLoader, the dojo.provide by user
    const hookRes = await (this.customLoader &&
      this.customLoader(provider as interfaces.Provider, appInfo, basename));

    if (hookRes) {
      const { mount, unmount } = hookRes || ({} as any);
      if (typeof mount === 'function' && typeof unmount === 'function') {
        (provider as interfaces.Provider).render = mount;
        (provider as interfaces.Provider).destroy = unmount;
      }
    }

    if (!appInfo.noCheckProvider) {
      assert(provider, `"provider" is "${provider}".`);
      // No need to use "hasOwn", because "render" may be on the prototype chain
      assert('render' in provider, '"render" is required in provider.');
      assert('destroy' in provider, '"destroy" is required in provider.');
    }

    this.provider = provider as interfaces.Provider;
    return provider as interfaces.Provider;
  }
  ...
}

一般情况下,provider是通过appInstance.cjsModules.exports获取的。而子应用入口导出的内容是在脚本执行期间传递给appInstance.cjsModules.exports的。

首先,在子应用实例创建时,会初始化appInstance.cjsModules

js 复制代码
export class App {
  ...
  constructor(
    context: Garfish,
    appInfo: AppInfo,
    entryManager: TemplateManager,
    resources: interfaces.ResourceModules,
    isHtmlMode: boolean,
    customLoader?: CustomerLoader,
  ) {
    ...
    this.cjsModules = {
      exports: {},
      module: null,
      require: (key: string) => {
        const pkg = this.global[key] || context.externals[key] || window[key];
        if (!pkg) {
          warn(`Package "${key}" is not found`);
        }
        return pkg;
      },
    };
    this.cjsModules.module = this.cjsModules;
    ...
  }
  ...
}

而脚本执行是通过execScript函数实现的:

js 复制代码
export class App {
  ...
  execScript(
    code: string,
    env: Record<string, any>,
    url?: string,
    options?: interfaces.ExecScriptOptions,
  ) {
    env = {
      ...this.getExecScriptEnv(options?.noEntry),
      ...(env || {}),
    };

    this.scriptCount++;

    const args = [this.appInfo, code, env, url, options] as const;
    this.hooks.lifecycle.beforeEval.emit(...args);
    try {
      this.runCode(code, env, url, options);
    } catch (err) {
      this.hooks.lifecycle.errorExecCode.emit(err, ...args);
      throw err;
    }

    this.hooks.lifecycle.afterEval.emit(...args);
  }
  
  getExecScriptEnv(noEntry?: boolean) {
    // The legacy of commonJS function support
    const envs = {
      [__GARFISH_EXPORTS__]: this.customExports,
      [__GARFISH_GLOBAL_ENV__]: this.globalEnvVariables,
    };

    if (noEntry) {
      return {
        ...envs,
        require: this.cjsModules.require,
      };
    }

    return {
      ...envs,
      ...this.cjsModules,
    };
  }
  ...
}

noEntry在一般情况下为false,因此execScript中,env.module引用的对象是appInstance.cjsModules。之后env会被传递给runCode函数。默认情况下,Garfish使用的是vm沙箱,vm沙箱会将App实例的runCode函数替换为vm沙箱Sandbox实例的execScript函数。Sandbox.execScript最终通过evalWithEnv函数执行脚本:

js 复制代码
export function evalWithEnv(
  code: string,
  params: Record<string, any>,
  context: any,
  useStrict = false,
) {
  const keys = Object.keys(params);
  const nativeWindow = (0, eval)('window;');
  // No random value can be used, otherwise it cannot be reused as a constant string
  const randomValKey = '__garfish__exec_temporary__';
  const values = keys.map((k) => `window.${randomValKey}.${k}`);
  const contextKey = '__garfish_exec_temporary_context__';

  try {
    nativeWindow[randomValKey] = params;
    nativeWindow[contextKey] = context;
    const evalInfo = [
      `;(function(${keys.join(',')}){${useStrict ? '"use strict";' : ''}`,
      `\n}).call(window.${contextKey},${values.join(',')});`,
    ];
    const internalizeString = internFunc(evalInfo[0] + code + evalInfo[1]);
    // (0, eval) This expression makes the eval under the global scope
    (0, eval)(internalizeString);
  } catch (e) {
    throw e;
  } finally {
    delete nativeWindow[randomValKey];
    delete nativeWindow[contextKey];
  }
}

经过一系列中间转换过程,在执行(0, eval)(internalizeString)之前,env被赋值给了window.__garfish__exec_temporary__.__GARFISH_SANDBOX_ENV_VAR__,因此window.__garfish__exec_temporary__.__GARFISH_SANDBOX_ENV_VAR__.module是指向appInstance.cjsModules的。而要执行的代码也会被Garfish进行一系列处理,最终internalizeString的情况如下所示:

js 复制代码
(function (
  window,
  WebSocket,
  XMLHttpRequest,
  fetch,
  setTimeout,
  clearTimeout,
  setInterval,
  clearInterval,
  setImmediate,
  history,
  History,
  document,
  addEventListener,
  removeEventListener,
  MutationObserver,
  MouseEvent,
  localStorage,
  sessionStorage,
  __GARFISH_SANDBOX_ENV_VAR__
) {
  with (window) {
    ...
    let __GARFISH_EXPORTS__ = __GARFISH_SANDBOX_ENV_VAR__.__GARFISH_EXPORTS__;
    let __GARFISH_GLOBAL_ENV__ =
      __GARFISH_SANDBOX_ENV_VAR__.__GARFISH_GLOBAL_ENV__;
    let exports = __GARFISH_SANDBOX_ENV_VAR__.exports;
    // 可见这里的module是 window.__garfish__exec_temporary__.__GARFISH_SANDBOX_ENV_VAR__.module,
    // 也就是appInstance.cjsModules
    let module = __GARFISH_SANDBOX_ENV_VAR__.module;
    let require = __GARFISH_SANDBOX_ENV_VAR__.require;
    // 前面部分都是Garfish添加的,中间部分才是请求到的脚本内容
    // Garfish要求子应用的构建配置中,libraryTarget使用umd,因此子应用脚本必然是下面的格式
    (function webpackUniversalModuleDefinition(root, factory) {
      if (typeof exports === "object" && typeof module === "object")
        // 这里将appInstance.cjsModules.exports赋值为子应用的导出内容
        module.exports = factory();
      else if (typeof define === "function" && define.amd) define([], factory);
      else {
        var a = factory();
        for (var i in a)
          (typeof exports === "object" ? exports : root)[i] = a[i];
      }
    })(window, () => {
      return ...
    });
    // 后面部分都是Garfish添加的,中间部分才是请求到的脚本内容
  }
}).call(
  window.__garfish_exec_temporary_context__,
  window.__garfish__exec_temporary__.window,
  window.__garfish__exec_temporary__.WebSocket,
  window.__garfish__exec_temporary__.XMLHttpRequest,
  window.__garfish__exec_temporary__.fetch,
  window.__garfish__exec_temporary__.setTimeout,
  window.__garfish__exec_temporary__.clearTimeout,
  window.__garfish__exec_temporary__.setInterval,
  window.__garfish__exec_temporary__.clearInterval,
  window.__garfish__exec_temporary__.setImmediate,
  window.__garfish__exec_temporary__.history,
  window.__garfish__exec_temporary__.History,
  window.__garfish__exec_temporary__.document,
  window.__garfish__exec_temporary__.addEventListener,
  window.__garfish__exec_temporary__.removeEventListener,
  window.__garfish__exec_temporary__.MutationObserver,
  window.__garfish__exec_temporary__.MouseEvent,
  window.__garfish__exec_temporary__.localStorage,
  window.__garfish__exec_temporary__.sessionStorage,
  window.__garfish__exec_temporary__.__GARFISH_SANDBOX_ENV_VAR__
);

根据上面的注释可见,这段代码执行之后,子应用的导出内容会被赋值给appInstance.cjsModules.exports,因此checkAndGetProvider函数可以从中获取provider。

调用provider.render

获取provider之后,Garfish通过调用provider.render完成子应用内容的渲染。以React子应用为例,一般会在provider.render中调用ReactDOM.render,将根组件挂载到指定的节点上。

在调用provider.render、等待async脚本执行完成之后,子应用mount即完成。至此,子应用加载的全流程已完成,子应用的内容最终显示在浏览器中。

子应用生命周期

前面的路由部分提到Garfish在路由变化时会通过linkTo函数来处理子应用的变更。对于要显示的子应用会调用active函数,不再显示的子应用调用deactive函数。active和deactive都定义在路由插件的bootstrap中

js 复制代码
export function GarfishRouter(_args?: Options) {
  return function (Garfish: interfaces.Garfish): interfaces.Plugin {
    Garfish.apps = {};
    Garfish.router = router;

    return {
      ...
      bootstrap(options: interfaces.Options) {
        ...
        const unmounts: Record<string, Function> = {};
        ...
        async function active(
          appInfo: interfaces.AppInfo,
          rootPath: string = '/',
        ) {
          ...
          const currentApp = (activeApp = createKey());
          const app = await Garfish.loadApp(appInfo.name, {
            cache,
            basename: rootPath,
            entry: appInfo.entry,
            domGetter: appInfo.domGetter,
          });

          if (app) {
            app.appInfo.basename = rootPath;

            const call = async (app: interfaces.App, isRender: boolean) => {
              if (!app) return;
              const isDes = cache && app.mounted;
              if (isRender) {
                return await app[isDes ? 'show' : 'mount']();
              } else {
                return app[isDes ? 'hide' : 'unmount']();
              }
            };

            Garfish.apps[name] = app;
            unmounts[name] = () => {
              // Destroy the application during rendering and discard the application instance
              if (app.mounting) {
                delete Garfish.cacheApps[name];
              }
              call(app, false);
            };

            if (currentApp === activeApp) {
              await call(app, true);
            }
          }
        }

        async function deactive(appInfo: interfaces.AppInfo, rootPath: string) {
          ...
          const unmount = unmounts[name];
          unmount && unmount();
          delete Garfish.apps[name];
          ...
        }
        ...
      },
      ...
    };
  };
}

active和deactive都会调用call函数,在call函数中触发子应用的生命周期,完成子应用的挂载、卸载。子应用一共有四个生命周期:mount、unmount、show、hide。子应用初次加载时,触发的一定是mount。后续触发情况和缓存选项cache有关:

  • 当cache为true时(默认情况),子应用卸载会触发hide,再次加载会触发show
  • 当cache为false时,子应用卸载会触发unmount,再次加载会触发mount

mount在前面已经介绍过了,这里介绍一下另外三个生命周期

js 复制代码
export class App {
  ...
  async show() {
    this.active = true;
    const { display, mounted, provider } = this;
    if (display) return false;
    if (!mounted) {
      __DEV__ && warn('Need to call the "app.mount()" method first.');
      return false;
    }
    this.hooks.lifecycle.beforeMount.emit(this.appInfo, this, true);
    this.context.activeApps.push(this);

    await this.addContainer();
    this.callRender(provider, false);
    this.display = true;
    this.hooks.lifecycle.afterMount.emit(this.appInfo, this, true);
    return true;
  }

  hide() {
    this.active = false;
    this.mounting = false;
    const { display, mounted, provider } = this;
    if (!display) return false;
    if (!mounted) {
      __DEV__ && warn('Need to call the "app.mount()" method first.');
      return false;
    }
    this.hooks.lifecycle.beforeUnmount.emit(this.appInfo, this, true);

    this.callDestroy(provider, false);
    this.display = false;
    remove(this.context.activeApps, this);
    this.hooks.lifecycle.afterUnmount.emit(this.appInfo, this, true);
    return true;
  }
  
  unmount() {
    this.active = false;
    this.mounting = false;
    if (!this.mounted || !this.appContainer) {
      return false;
    }
    if (this.unmounting) {
      __DEV__ && warn(`The ${this.name} app unmounting.`);
      return false;
    }
    // This prevents the unmount of the current app from being called in "provider.destroy"
    this.unmounting = true;
    this.hooks.lifecycle.beforeUnmount.emit(this.appInfo, this, false);

    try {
      this.callDestroy(this.provider, true);
      this.display = false;
      this.mounted = false;
      this.provider = undefined;
      this.customExports = {};
      this.cjsModules.exports = {};
      this.esModuleLoader.destroy();
      remove(this.context.activeApps, this);
      this.hooks.lifecycle.afterUnmount.emit(this.appInfo, this, false);
    } catch (e) {
      remove(this.context.activeApps, this);
      this.entryManager.DOMApis.removeElement(this.appContainer);
      this.hooks.lifecycle.errorUnmountApp.emit(e, this.appInfo);
      return false;
    } finally {
      this.unmounting = false;
    }
    return true;
  }
  
  // Call to destroy do compatible with two different sandbox
  private callDestroy(provider?: interfaces.Provider, isUnmount?: boolean) {
    const { rootElement, appContainer } = this;
    if (provider && provider.destroy) {
      provider.destroy({
        appName: this.appInfo.name,
        dom: rootElement,
        appRenderInfo: { isUnmount },
        props: this.appInfo.props,
      });
    }
    this.entryManager.DOMApis.removeElement(appContainer);
  }
  ...
}

unmount和hide都会调用provider.destroy,然后移除用于挂载子应用DOM树的appContainer。与hide相比,unmount会重置更多的状态。show会重新挂载appContainer,并调用provider.render渲染子应用内容。

相关推荐
猫头虎-前端技术6 小时前
HTML 与 CSS 的布局机制(盒模型、盒子定位、浮动、Flexbox、Grid)问题总结大全
前端·javascript·css·vue.js·react.js·前端框架·html
stoneSkySpace12 小时前
react 自定义状态管理库
前端·react.js·前端框架
昭福前端语录1 天前
一套代码构建B端企业管理系统跨端方案——从原理到落地解决方案
前端框架·设计·前端工程化
昕冉2 天前
双碳系统之UML图
前端框架·uml
伍哥的传说2 天前
React Toast组件Sonner使用详解、倒计时扩展
前端·javascript·react.js·前端框架·ecmascript
徐志伟啊2 天前
ElTree组件可以带线了?
前端·vue.js·前端框架
小白变怪兽2 天前
一、react18+项目初始化
前端·react.js·前端框架
晴殇i2 天前
CSS Grid 布局中添加分隔线的4种实用方法
前端·css·前端框架
yma163 天前
react_flow自定义节点、边——使用darg布局树状结构
前端·react.js·前端框架·reac_flow
TeamDev3 天前
在 Java 应用中构建双向数据保护
java·前端框架·全栈