插件指南

Garfish 框架引入了插件化机制,目的是为了让开发者能够通过编写插件的方式来扩展更多功能,或为自身业务定制个性化功能;同时框架的基础能力也都是通过插件机制来实现,确保框架核心足够精简和稳定。

插件能做什么

插件的功能范围没有严格的限制,一般有下面两种:

  1. 添加全局方法或增加默认参数
  2. 在应用的生命周期中自定义功能(例如:Garfish routerGarfish sandbox

编写插件

Garfish Router 增加了全局方法和应用的自动渲染和销毁能力,下面让我们来以 Garfish router 为例,如何编写一个插件,来实现路由的能力。

当插件被注册到 Garfish 框架时,将会调用插件函数并将 GarfishInstance 作为参数传递,函数的返回值中包括插件的基本信息:nameversion,除了基本信息外最重要的则是包括 hookGarfish 框架将在应用的整个生命周期中触发 hook 的调用,可以在 hook 中对信息进行二次处理或执行特定的功能。

让我们从编写插件函数开始,建议在单独的文件中创建它并将其导出,如下所示,以保持插件逻辑的整洁和分离,在实际开发过程中我们建议将实际插件的内容放置一个函数中返回,以便插件在实际调用时可接收参数

// plugins/router.ts
import type { interfaces } from 'garfish';
// function return plugin
export function GarfishRouter(_args?: any) {
  // Plugin code goes here
  return function (GarfishInstance: interfaces.Garfish): interfaces.Plugin {
    return {
      name: 'garfish-router',
      version: '1.2.1',
      // ...
    };
  };
}

Garfish Router 的这个 plugin 期望达到的目标是通过提供的 Router MapGarfish 框架能够自动的完成微前端应用的渲染和销毁调度,从而降低典型中台中管理应用销毁和渲染的工作,提升开发效率。那么要实现这个需求我们需要依次实现以下功能:

  • 扩展类型
    • appInfo 的类型,让 appInfo 类型提示支持 activeWhenbasename 等配置
    • Garfish 增加 router 类型
  • Garfish 实例扩展 router 方法,用于实现路由跳转和路由监听等能力
  • 监听 bootstrap hook(该 hook 会在主应用触发 Garfish.run 后调用),触发 bootstrap
    • 劫持路由变化:改写 history.pushhistory.replace,监听 popstate 浏览器后退事件
    • 当路由发生变化时通过 appInfoactiveWhen 进行规则判断,对应用进行渲染和销毁
  • 监听 registerApp hook(该 hook 会在注册子应用时触发)
    • 当有新注册应用时对新应用进行检验是否如何渲染条件,进行销毁
TIP

Garfish Router 就是通过 Garfish 的 Plugin 机制实现,以下案例精简了大部分逻辑,主要介绍如何编写插件来扩展 Garfish 的整体功能,若想了解实现,请参考 Garfish Router plugin

import type { interfaces } from 'garfish';
declare module 'garfish' {
  // 为 GarfishInstance 添加 router 方法
  export default interface Garfish {
    router: {
      push: (info: {
        path: string;
        query?: { [key: string]: string };
        basename?: string;
      }) => void;
      replace: (info: {
        path: string;
        query?: { [key: string]: string };
        basename?: string;
      }) => void;
    };
  }

  export namespace interfaces {
    // 为全局配置增加 autoRefreshApp、onNotMatchRouter 参数类型
    export interface Config {
      onNotMatchRouter?: (path: string) => Promise<void> | void;
    }

    export interface AppInfo {
      // 手动加载,可不填写路由
      activeWhen?: string | ((path: string) => boolean);
      basename?: string;
    }
  }
}

// 这里仅做伪代码的演示,功能无法正常运行
export function GarfishRouter(_args?: { autoRefreshApp?: boolean }) {
  return function (Garfish: interfaces.Garfish): interfaces.Plugin {
    // 为 Garfish 实例添加 router 方法
    Garfish.router = {
      push: ({ path }) => history.push(null, '', path),
      replace: ({ path }) => history.replace(null, '', path),
    };

    return {
      name: 'router',
      version: '1.0.0',
      // 在触发 Garfish.run 后启动路由监听,自动渲染和销毁应用
      bootstrap(options: interfaces.Options) {
        let activeApp = null;
        const unmounts: Record<string, Function> = {};
        const { basename } = options;

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

        // 该函数会劫持 history 变化,当某个 appInfo 的 activeWhen 符合触发条件后会触发 active 回调
        // 提供 appInfo 信息,这个时候通过 Garfish.loadApp 加载该应用并进行销毁
        // 当某个 appInfo 处于已经渲染状态,并且在路由发生变化后处于销毁状态将会触发 deactive 回调
        // 通过 appInfo,触发缓存的 app 实例的销毁函数
        listenRouterAndReDirect({
          basename,
          active: async (appInfo: interfaces.AppInfo, rootPath: string) => {
            const { name, cache = true, active } = appInfo;

            // 当前应用处于激活状态后触发
            const app = await Garfish.loadApp(appInfo.name, {
              basename: rootPath,
              entry: appInfo.entry,
              cache: true,
              domGetter: appInfo.domGetter,
            });

            if (app) {
              const isDes = cache && app.mounted;
              isDes ? await app.show() : await app.mount();

              unmounts[name] = () => {
                const isDes = cache && app.mounted;
                isDes ? await app.show() : await app.mount();
              };
            }
          },
          deactive: async (appInfo: interfaces.AppInfo, rootPath: string) => {
            const { name, deactive } = appInfo;
            const unmount = unmounts[name];
            unmount && unmount();
          },
          autoRefreshApp,
          notMatch: onNotMatchRouter,
          apps,
          listening: true,
        });
      },

      registerApp(appInfos) {
        // 将新注册的应用信息注入到路由中
        const appList = Object.values(appInfos);
        router.registerRouter(appList.filter((app) => !!app.activeWhen));
        // 触发路由的重定向,检测当前应用是否需要触发渲染
        initRedirect();
      },
    };
  };
}

插件编写总结

  • 若要为 Garfish 实例扩展方法,通过 declare module 直接扩展 Garfishinterfaces,然后通过插件函数获取 Garfish 的实例直接添加方法,用于扩展 Garfish 的能力
  • 可通过 namespace interfaces 直接扩展 Garfish configAppInfo 配置
  • 在对应用用的生命周期中进行能力的扩展

插件公约

  • 插件应该包括清晰的名称
  • 如果插件单独封装至 npm 包,在 package.json 中添加 garfish-plugin 关键词
  • 插件应该包括完备的测试
  • 插件应该具备完整的使用文档
  • 如果你觉得你的插件足够通用,请联系:zhouxiao.shaw@bytedance.com,评估后是否是和加入推荐列表

使用插件

通过调用 Garfish.usePlugin 方法将插件添加到你的应用程序中。

我们将使用在 如何编写插件 部分中创建的 routerPlugin 插件进行演示。

usePlugin() 方法第一个参数接收要安装的插件,在这种情况下为 routerPlugin 的返回值。

它还会自动阻止你多次使用同一插件,因此在同一插件上多次调用只会安装一次该插件,Garfish 内部通过插件执行后返回的 name 作为唯一标识来进行区分,在进行插件命名时,请确保不会和其他插件之间发生冲突。

import Garfish from 'garfish';
import routerPlugin from './plugins/router';

Garfish.usePlugin(routerPlugin());

usePlugin

通过 Garfish.usePlugin 可以注册插件

Garfish.usePlugin(plugin: (GarfishInstance: interfaces.Garfish)=> interfaces.Plugin)

plugin

name

  • Type: string
  • 插件的名称,作为插件的唯一标识和便于调试

version?

  • Type: string
  • 插件的版本号,用于观测线上环境使用使用的插件版本

beforeBootstrap?

  • Type: (options: interfaces.Options) => void
    • hook 的第一个参数为 Garfish.run 提供的配置信息
  • Kind: sync, sequential
  • Trigger:
    • Garfish.run 调用后触发
    • 触发该 hook 时配置未注册到全局

bootstrap?

  • Type: (options: interfaces.Options) => void
    • hook 的第一个参数为 Garfish.run 提供的配置信息
  • Kind: sync, sequential
  • Trigger:
    • Garfish.run 调用后触发
    • 触发该 hook 时配置已经注册到全局
  • Previous Hook: beforeBootstrap

beforeRegisterApp?

  • Type: (appInfo: interfaces.AppInfo | Array<interfaces.AppInfo>) => void
    • hook 的第一个参数为需要注册的应用信息
  • Kind: sync, sequential
  • Trigger:
    • 调用 Garfish.run 且,提供了 apps 参数时触发
    • 直接通过 Garfish.registerApp 调用时
    • 触发该 hook 是子应用信息尚未注册成功

registerApp?

  • Type: (appInfo: interfaces.AppInfo | Array<interfaces.AppInfo>) => void
    • hook 的第一个参数为需要注册的应用信息
  • Kind: sync, sequential
  • Trigger:

beforeLoad

  • Type: async (appInfo: AppInfo, appInstance: App) => false | undefined

    • hook 的参数分别为:应用信息、应用实例;
    • 当返回 false 时将中断子应用的加载及后续流程;
  • Kind: async, sequential

  • Trigger:

    • 在调用 Garfish.load 时触发该 hook
    • 子应用加载前触发,此时还未开始加载子应用资源;
  • 示例

Garfish.run({
  ...,
  beforeLoad(appInfo) {
    console.log('子应用开始加载', appInfo.name);
  }

afterLoad

  • Type: async (appInfo: AppInfo, appInstance: interfaces.App) => void

  • hook 的参数分别为:应用信息、应用实例;

  • Kind: async, sequential

  • Trigger:

    • 在调用 Garfish.load 后并且子应用加载完成时触发该 hook
  • 示例

Garfish.run({
  ...,
  afterLoad(appInfo) {
    console.log('子应用加载完成', appInfo.name);
  }
})

errorLoadApp

  • Type: (error: Error, appInfo: AppInfo, appInstance: interfaces.App) => void

    • hook 的参数分别为:error 实例、 appInfo 信息、appInstance 应用实例
    • 一旦设置该 hook,子应用加载错误不会 throw 到文档流中,全局错误监听将无法捕获到;
  • Kind: sync, sequential

  • Trigger:

    • 在调用 Garfish.load 过程中,并且加载失败时触发该 hook
  • 示例

Garfish.run({
  ...,
  errorLoadApp(error, appInfo) {
    console.log('子应用加载异常', appInfo.name);
    console.error(error);
  }
})

beforeMount

  • Type: (appInfo: AppInfo, appInstance: interfaces.App, cacheMode: boolean) => void

    • hook 的参数分别为:appInfo 信息、appInstance 应用实例、是否为 缓存模式 渲染和销毁
  • Kind: sync, sequential

  • Previous Hook: beforeEvalafterEval

  • Trigger:

    • 此时子应用资源准备完成,运行时环境初始化完成,准备开始渲染子应用 DOM 树;
    • 在调用 app.mountapp.show 触发该 hook,用户除了手动调用这两个方法外,Garfish Router 托管模式还会自动触发
      • 在使用 app.mount 渲染应用是 cacheModefalse
      • 在使用 app.show 渲染应用是 cacheModetrue
  • 示例

Garfish.run({
  ...,
  beforeMount(appInfo) {
    console.log('子应用开始渲染', appInfo.name);
  }
})

afterMount

  • Type: (appInfo: AppInfo, appInstance: interfaces.App, cacheMode: boolean) => void

    • hook 的参数分别为:appInfo 信息、appInstance 应用实例、是否为 缓存模式 渲染和销毁
  • Kind: sync, sequential

  • Previous Hook: beforeLoadafterLoadbeforeMount

  • Trigger:

    • 此时子应用 DOM 树已渲染完成,garfish 实例 activeApps 中已添加当前子应用 app 实例;
    • 在挂载过程中,会调用应用生命周期中的 render 函数,用户可在挂载前定义相关操作;
    • 若挂载过程中出现异常,会触发 errorMountApp,同时会清除已创建的 app 渲染容器 appContainer
  • 示例

Garfish.run({
  ...,
  afterMount(appInfo) {
    console.log('子应用渲染结束', appInfo.name);
  }
})

beforeEval

  • Type: (appInfo: AppInfo, code: string, env: Record<string, any>, url: string, options) => void

    • hook 的参数分别为:appInfo 信息、code 执行的代码、env 要注入的环境变量,url 代码的资源地址、options 参数选项(例如 async 是否异步执行、noEntry 是否是 noEntry 模式);
  • Kind: sync, sequential

  • Previous Hook: beforeMount

  • Trigger:

    • 在子应用挂载过程中、实际执行代码前触发该 hook;
    • 应用 html 内的 script 和动态创建的脚本执行时都会触发该 hook
    • 此时 DOM 树已添加至文档流中,子应用代码准备执行;
    • 若代码执行过程中抛出异常,则将触发 errorMountApp,否则触发 beforeEval
  • 示例

Garfish.run({
  ...,
  beforeEval(appInfo) {
    console.log('子应用代码开始执行', appInfo.name);
  }
})

afterEval

  • Type: (appInfo: AppInfo, code: string, env: Record<string, any>, url: string, options) => void

    • hook 的参数分别为:appInfo 信息、code 执行的代码、env 要注入的环境变量,url 应用访问地址、options 参数选项例如 async 是否异步执行、noEntry 是否是 noEntry 模式;
  • Kind: sync, sequential

  • Previous Hook: beforeLoadafterLoad

  • Trigger:

    • 在实际执行代码后。afterMount 触发前触发;
    • 子应用 html 内的 script 和动态创建的脚本执行时都会触发该 hook
  • 示例

Garfish.run({
  ...,
  afterEval(appInfo) {
    console.log('子应用代码执行完成', appInfo.name);
  }
})

errorMountApp

  • Type: (error: Error, appInfo: AppInfo, appInstance: interfaces.App) => void

    • 一旦设置该 hook,子应用加载错误不会 throw 到文档流中,全局错误监听将无法捕获到;
  • Kind: sync, sequential

  • Previous Hook: beforeLoadafterLoadbeforeMountafterMount

  • Trigger:

    • 在渲染过程中出现异常会触发该 hook,子应用同步执行的代码出现异常会触发该 hook,异步代码无法触发
  • 示例

Garfish.run({
  ...,
  errorMountApp(error, appInfo) {
    console.log('子应用渲染异常', appInfo.name);
    console.error(error);
  }
})

beforeUnmount

  • Type: ( appInfo: AppInfo, appInstance: interfaces.App) => void
  • Kind: sync, sequential
  • Previous Hook: beforeLoadafterLoadbeforeMountafterMount
  • Trigger:
    • 在调用 app.unmountapp.hide 触发该 hook,用户除了手动调用这两个方法外,Garfish Router 托管模式还会自动触发

      • 在使用 app.unmount 渲染应用是 cacheModefalse
      • 在使用 app.hide 渲染应用是 cacheModetrue
    • 此时子应用 DOM 元素还未卸载,副作用尚未清除;

    • 此时子应用 DOM 树已渲染完成,garfish 实例 activeApps 中已添加当前子应用 app 实例;

afterUnmount

  • Type: ( appInfo: AppInfo, appInstance: interfaces.App) => void
  • Kind: sync, sequential
  • Trigger:
    • 此时,应用在渲和运行过程中产生的副作用已清除,DOM 已卸载,沙箱副作用已清除,garfish 实例 activeApps 当前 app 已移除;

    • 在应用销毁过程中会调用应用生命周期中的 destory 函数,用户可在销毁前定义相关操作;

    • 若应用卸载过程中出现异常,会触发 errorUnmountApp

errorUnmountApp

  • Type: (error: Error, appInfo: AppInfo, appInstance: interfaces.App)=> void

    • 一旦设置该 hook,子应用销毁错误不会向上 throw 到文档流中,全局错误监听将无法捕获到;
  • Kind: sync, sequential

  • Trigger:

    • app.unmountapp.hide 销毁过程中出现异常则会触发该 hook,用户除了手动调用这两个方法外,Garfish Router 托管模式还会自动触发
  • 示例

Garfish.run({
  ...,
  errorUnmountApp(error, appInfo) {
    console.log('子应用销毁异常', appInfo.name);
    console.error(error);
  }
})

onNotMatchRouter

  • Type: (path: string)=> void

    • hook 的参数分别为:应用信息、应用实例;
  • Kind: sync, sequential

  • Trigger:

    • 路由发生变化当前未激活子应用且未匹配到任何子应用时触发
  • 示例

Garfish.run({
  ...,
  onNotMatchRouter(path) {
    console.log('未匹配到子应用', path);
  }
})