umi运行时插件机制

1,191 阅读6分钟

运行时配置(内置的运行时插件)

modifyClientRenderOpts(fn)

修改 clientRender 参数。

let isSubApp = false;export function modifyClientRenderOpts(memo) {  return {    ...memo,    rootElement: isSubApp ? 'sub-root' : memo.rootElement,      };}

patchRoutes({ routes })

patchRoutes 运行时插件的功能在于追加或修改路由。其流程为:编译阶段获得配置路由或扫描文件路由,然后在运行时对这些路由进行再处理。

比如在最前面添加一个 /test 路由,

import Test from '@/pages/test'export function patchRoutes({ routes }) {  routes.unshift({    path: '/test',    exact: true,    component: Test,    title: 'test'  });}
  • 直接修改routes,不需要返回

render(oldRender: Function)

覆写 renderrender 运行时插件的功能在于对渲染脚本进行封装。

比如用于渲染之前做权限校验,

import { history } from 'umi';export function render(oldRender) {  fetch('/api/auth').then(auth => {    if (auth.isLogin) { oldRender() }    else {       history.push('/login');       oldRender()    }  });}

onRouteChange({ routes, matchedRoutes, location, action })

onRouteChange 运行时插件的功能在于监听路由变更信息。

比如用于设置标题,

export function onRouteChange({ matchedRoutes }) {  if (matchedRoutes.length) {    document.title = matchedRoutes.[matchedRoutes.length - 1].route.title || '';  }}

rootContainer(LastRootContainer, args)

修改交给 react-dom 渲染时的根组件。

比如用于在外面包一个 Provider

export function rootContainer(container) {  return React.createElement(ThemeProvider, null, container);}

args 包含:

  • routes,全量路由配置
  • plugin,运行时插件机制
  • history,history 实例

自定义运行时插件

运行时插件也遵循先注册、后使用的流程。运行时插件表现为数组形式,数组项为运行时插件的导出对象。导出对象中的属性可以是函数、或对象、返回对象的 Promise(前者可称为执行类导出,后两者可称为配置类导出,因其用于获取配置项)

要自定义一个运行时插件,需要先自定义一个编译时插件,用于注册运行时插件

注册

addRuntimePlugin添加运行时插件,返回值格式为文件的绝对路径

addRuntimePluginKey添加运行时可配置项。

import { IApi } from "umi";export default (api: IApi, opts) => {  // 需要注册的插件所在的文件的绝对路径,也可以不需要,插件函数在约定文件src/app.ts即可  api.addRuntimePlugin(() => require.resolve('../src/plugin/runtime'));  // 插件名 会加入到validKeys中  api.addRuntimePluginKey(() => ['myPlugin1', 'myPlugin2']);}

然后在 runtime.ts 是以下内容:

export function myPlugin1(props) {// routes,全量路由配置// plugin,运行时插件机制// historyhistory 实例
  cosnt { history, plugin, routes } = props// do something}export function myPlugin2(props) {
  // do something
}

执行

export function rootContainer(container, args) {   args.plugin.applyPlugins({    key: 'myPlugin1',    type: ApplyPluginsType.event,    args   })   args.plugin.applyPlugins({    key: 'myPlugin2',    type: ApplyPluginsType.event,    args   })  return React.createElement(Provider, null, container);}

插件生成及运行

入口文件

// path: src/.umi/umi.ts
// umi临时文件// @ts-nocheckimport './core/polyfill';import '@@/core/devScripts';import { plugin } from './core/plugin';import './core/pluginRegister';import { createHistory } from './core/history';import { ApplyPluginsType } from '/Users/pcc/project/umi-share/node_modules/umi/node_modules/@umijs/runtime';import { renderClient } from '/Users/pcc/project/umi-share/node_modules/@umijs/renderer-react/dist/index.js';import { getRoutes } from './core/routes';const getClientRender = (args: { hot?: boolean; routes?: any[] } = {}) => plugin.applyPlugins({  key: 'render',  type: ApplyPluginsType.compose,  initialValue: () => {    const opts = plugin.applyPlugins({      key: 'modifyClientRenderOpts',      type: ApplyPluginsType.modify,      initialValue: {        routes: args.routes || getRoutes(),        plugin,        history: createHistory(args.hot),        isServer: process.env.__IS_SERVER,        rootElement: 'root',        defaultTitle: ``,      },    });    return renderClient(opts);  },  args,});const clientRender = getClientRender();export default clientRender();
// ...

通过执行getClientRender函数,返回clientRender。在getClientRender函数内部可以看到有plugin,运行时阶段同样通过插件化plugin.applyPlugins返回渲染需要的render方法。

插件是如何实现的

// path: ~/umi/packages/runtime/src/Plugin/Plugin.ts
// umi源码export default class Plugin {  validKeys: string[];  hooks: {    [key: string]: any;  } = {};  constructor(opts?: IOpts) {    // 初始化阶段是就定义好了允许注册hook的key,后续也不允许修改    this.validKeys = opts?.validKeys || [];  }

  // 注册  register(plugin: IPlugin) {    assert(!!plugin.apply, `register failed, plugin.apply must supplied`);    assert(!!plugin.path, `register failed, plugin.path must supplied`);    Object.keys(plugin.apply).forEach((key) => {    // 通过validKeys校验是否允许注册当前插件      assert(        this.validKeys.indexOf(key) > -1,        `register failed, invalid key ${key} from plugin ${plugin.path}.`,      );      if (!this.hooks[key]) this.hooks[key] = [];      // 收集hooks      this.hooks[key] = this.hooks[key].concat(plugin.apply[key]);    });  }

  // 获取hook  getHooks(keyWithDot: string) {    // 支持普通方式注册'test',也支持'a.b.c'的注册方式    const [key, ...memberKeys] = keyWithDot.split('.');    let hooks = this.hooks[key] || [];    if (memberKeys.length) {      hooks = hooks        .map((hook: any) => {          try {            let ret = hook;            for (const memberKey of memberKeys) {              ret = ret[memberKey];            }            return ret;          } catch (e) {            return null;          }        })        .filter(Boolean);    }    return hooks;  }

  // 执行hook  // 支持modify event compose三种类型hook  // export enum ApplyPluginsType {  //   compose = 'compose',  //   modify = 'modify',  //   event = 'event',  // }
    applyPlugins({    key,    type,    initialValue,    args,    async,  }: {    key: string;    type: ApplyPluginsType;    initialValue?: any;    args?: object;    async?: boolean;  }) {    const hooks = this.getHooks(key) || [];    if (args) {      assert(        typeof args === 'object',        `applyPlugins failed, args must be plain object.`,      );    }    switch (type) {      case ApplyPluginsType.modify:        if (async) {          return hooks.reduce(            async (memo: any, hook: Function | Promise<any> | object) => {              assert(                typeof hook === 'function' ||                  typeof hook === 'object' ||                  isPromiseLike(hook),                `applyPlugins failed, all hooks for key ${key} must be function, plain object or Promise.`,              );              if (isPromiseLike(memo)) {                memo = await memo;              }              if (typeof hook === 'function') {                const ret = hook(memo, args);                if (isPromiseLike(ret)) {                  return await ret;                } else {                  return ret;                }              } else {                if (isPromiseLike(hook)) {                  hook = await hook;                }                return { ...memo, ...hook };              }            },            isPromiseLike(initialValue)              ? initialValue              : Promise.resolve(initialValue),          );        } else {          return hooks.reduce((memo: any, hook: Function | object) => {            assert(              typeof hook === 'function' || typeof hook === 'object',              `applyPlugins failed, all hooks for key ${key} must be function or plain object.`,            );            if (typeof hook === 'function') {              return hook(memo, args);            } else {              // TODO: deepmerge?              return { ...memo, ...hook };            }          }, initialValue);        }      case ApplyPluginsType.event:        return hooks.forEach((hook: Function) => {          assert(            typeof hook === 'function',            `applyPlugins failed, all hooks for key ${key} must be function.`,          );          hook(args);        });      case ApplyPluginsType.compose:        return () => {          return _compose({            fns: hooks.concat(initialValue),            args,          })();        };    }  }}
  1. Pluginhook在初始化阶段是就定义好了允许注册hookkey,后续也不允许修改,在register阶段注册插件时会通过validKeys校验是否允许注册当前插件。

  2. 通过getHooks获取指定key对应的hook,支持普通方式注册key='test',也支持key='a.b.c'的注册方式

  3. 调用时applyPlugins方法获取对应的hooks,并且支持modifyeventcompose三种类型的hook

临时文件plugin.ts

// path: src/.umi/core/plugin.ts
// umi临时文件

// @ts-nocheckimport { Plugin } from '/Users/pcc/project/umi-share/node_modules/umi/node_modules/@umijs/runtime';const plugin = new Plugin({  validKeys: ['modifyClientRenderOpts','patchRoutes','rootContainer','render','onRouteChange','__mfsu','getInitialState','initialStateConfig','locale','request',],});export { plugin };

// path: src/.umi/core/pluginRegister.ts
// umi临时文件// @ts-nocheckimport { plugin } from './plugin';import * as Plugin_0 from '../../app.tsx';import * as Plugin_1 from '../plugin-initial-state/runtime';import * as Plugin_2 from '../plugin-model/runtime';  // 默认运行时配置  plugin.register({    apply: Plugin_0,    path: '../../app.tsx',  });  plugin.register({    apply: Plugin_1,    path: '../plugin-initial-state/runtime',  });  plugin.register({    apply: Plugin_2,    path: '../plugin-model/runtime',  });export const __mfsu = 1;

这里实例化了内置的几个运行时插件并注册

分析上面代码,首先会从./core/plugin文件导入实例化好的plugin,同时在./core/pluginRegister中注册plugin,然后在入口执行key=renderhook获取渲染所需的render方法。

plugin.ts和pluginRegister.ts文件如何生成

// path: ~/umi/packages/preset-built-in/src/plugins/generateFiles/core/plugin.ts// umi源码包
export default function (api: IApi) {  api.onGenerateFiles(async (args) => {    // 调用构建时插件的addRuntimePluginKey的hook获取运行时插件所需的validKeys    const validKeys = await api.applyPlugins({      key: 'addRuntimePluginKey',      type: api.ApplyPluginsType.add,      initialValue: [        'modifyClientRenderOpts',        'patchRoutes',        'rootContainer',        'render',        'onRouteChange',        '__mfsu',      ],    });    // 调用构建时插件的addRuntimePlugin的hook获取运行时所需的插件    const plugins = await api.applyPlugins({      key: 'addRuntimePlugin',      type: api.ApplyPluginsType.add,      initialValue: [        getFile({          base: paths.absSrcPath!,          fileNameWithoutExt: 'app',          type: 'javascript',        })?.path,      ].filter(Boolean),    });    api.writeTmpFile({      path: 'core/plugin.ts',      content: Mustache.render(        readFileSync(join(__dirname, 'plugin.tpl'), 'utf-8'),        {          validKeys,          runtimePath,        },      ),    });    api.writeTmpFile({      path: 'core/pluginRegister.ts',      content: Mustache.render(        readFileSync(join(__dirname, 'pluginRegister.tpl'), 'utf-8'),        {          plugins: plugins.map((plugin: string, index: number) => {            return {              index,              path: winPath(plugin),            };          }),        },      ),    });  });  // ...}

分析生成文件plugin.ts的插件,我们可以看到这个插件主要做了4件事:

  1. 调用构建时插件addRuntimePluginKeyhook获取运行时插件所需的validKeys

    注:我们开发自定义插件时,可以通过addRuntimePluginKey注册自定义的validKey

  2. 调用构建时插件addRuntimePluginhook获取**运行时所需的插件

    注:我们可以看到运行时插件的默认值initialValue是从src/app文件中获取的,也就是在src/app文件中注册我们所需的hook,例如:通过patchRoutes更改路由、通过onRouteChange设置标题等操作

  3. 读取plugin.tpl模版,生成临时文件plugin.ts

  4. 读取pluginRegister.tpl模版,生成临时文件pluginRegister.ts

renderClient

// path: ~/umi/packages/renderer-react/src/renderClient/renderClient.tsx

export default function renderClient(opts: IOpts) {  const rootContainer = getRootContainer(opts);  if (opts.rootElement) {    const rootElement =      typeof opts.rootElement === 'string'        ? document.getElementById(opts.rootElement)        : opts.rootElement;    const callback = opts.callback || (() => {});    // flag showing SSR succeed    if (window.g_useSSR) {      if (opts.dynamicImport) {        // dynamicImport should preload current route component        // first loades);        preloadComponent(opts.routes).then(function () {          hydrate(rootContainer, rootElement, callback);        });      } else {        hydrate(rootContainer, rootElement, callback);      }    } else {      render(rootContainer, rootElement, callback);    }  } else {    return rootContainer;  }}

export default function getRootContainer(opts: IOpts) {  return opts.plugin.applyPlugins({    type: ApplyPluginsType.modify,    key: 'rootContainer',    initialValue: (      <RouterComponent        history={opts.history}        routes={opts.routes}        plugin={opts.plugin}        ssrProps={opts.ssrProps}        defaultTitle={opts.defaultTitle}      />    ),    args: {      history: opts.history,      routes: opts.routes,      plugin: opts.plugin,    },  });}

分析renderClient方法主要做了2件事:

  1. 触发rootContainer运行时阶段的hook,获取rootContainer组件
  2. 获取rootElementDOM元素
  3. rootContainer作为根组件,rootElement作为根元素,执行render方法,挂载组件,此时的render方法也就是react-dom导出的render方法

接下来,我们再看下rootContainer组件的initialValue

export default function RouterComponent(props: IRouterComponentProps) {  const { history, ...renderRoutesProps } = props;  useEffect(() => {    // first time using window.g_initialProps    // switch route fetching data, if exact route reset window.getInitialProps    if ((window as any).g_useSSR) {      (window as any).g_initialProps = null;    }    function routeChangeHandler(location: any, action?: string) {      const matchedRoutes = matchRoutes(        props.routes as RouteConfig[],        location.pathname,      );      // Set title      if (        typeof document !== 'undefined' &&        renderRoutesProps.defaultTitle !== undefined      ) {        document.title =          (matchedRoutes.length &&            // @ts-ignore            matchedRoutes[matchedRoutes.length - 1].route.title) ||          renderRoutesProps.defaultTitle ||          '';      }
      // 触发 onRouteChange 对应的 hook
      props.plugin.applyPlugins({        key: 'onRouteChange',        type: ApplyPluginsType.event,        args: {          routes: props.routes,          matchedRoutes,          location,          action,        },      });    }    routeChangeHandler(history.location, 'POP');    return history.listen(routeChangeHandler);  }, [history]);  return <Router history={history}>{renderRoutes(renderRoutesProps)}</Router>;}

这里RouterComponent返回的Router也就是react-router-dom包中导出的Router组件,同时在RouterComponent组件内部监听history,在history发生变化时触发运行时阶段的onRouteChange对应的hook,此时也就可以触发我们自己在业务代码中订阅的onRouteChange的回调了,例如:监听路由改变时,发送埋点或者改变document.title

完结

参考:juejin.cn/post/710223…