【umi】04 如何写一个umi plugin

215 阅读5分钟

analytics plugin 源码分析

看了下analytics的写法,发现这个plugin的写法比较简单,先从这个开始了解

api.describe

在插件注册阶段( initPresets or initPlugins stage )执行,用于描述插件或者插件集的 key、配置信息和启用方式等。

api.describe({ key?:string, config?: { default , schema, onChange }, enableBy? })
  • key 是配置中该插件配置的键名
  • config.default 是插件配置的默认值,当用户没有在配置中配置 key 时,默认配置将生效。
  • config.schema 用于声明配置的类型,基于 joi 。 如果你希望用户进行配置,这个是必须的 ,否则用户的配置无效
  • config.onChange 是 dev 模式下,配置被修改后的处理机制。默认值为 api.ConfigChangeType.reload,表示在 dev 模式下,配置项被修改时会重启 dev 进程。 你也可以修改为 api.ConfigChangeType.regenerateTmpFiles, 表示只重新生成临时文件。你还可以传入一个方法,来自定义处理机制。
  • enableBy 是插件的启用方式,默认是api.EnableBy.register,表示注册启用,即插件只要被注册就会被启用。可以更改为 api.EnableBy.config ,表示配置启用,只有配置插件的配置项才启用插件。你还可以自定义一个返回布尔值的方法( true 为启用 )来决定其启用时机,这通常用来实现动态生效。
 const enableBy = (opts: any) => {
   // 有analytics 或者 GA_KEY返回true
    return opts.config.analytics || GA_KEY;
  };
  
 api.describe({
    key: 'analytics',
    config: {
    //  声明入参的类型
      schema({ zod }) {
        return zod  
          .object({
            baidu: zod.string(),
            ga: zod.string(),
            ga_v2: zod.string(),
          })
          .partial();
      },
     // 表示在 dev 模式下,配置项被修改时会重启 dev 进程
     // 或者修改为 `api.ConfigChangeType.regenerateTmpFiles`, 表示只重新生成临时文件
      onChange: api.ConfigChangeType.reload,
    },
    // 默认值是api.EnableBy.register,为true的话表示注册就启用
    // 也可是设置为api.EnableBy.config,表示配置插件的配置项才启用插件
    enableBy, 
  });

api.addHTMLHeadScripts

往 HTML 的 <head> 元素里添加 Script。传入的 fn 不需要参数,且需要返回一个 string(想要加入的代码) 或者 { async?: boolean, charset?: string, crossOrigin?: string | null, defer?: boolean, src?: string, type?: string, content?: string } 或者它们的数组

// 根据配置,往head注入不同的script内容,非常好理解
 api.addHTMLHeadScripts(() => {
   const analytics = (api.config.analytics || {}) as IAnalyticsConfig;
   const { baidu, ga = GA_KEY, ga_v2 = GA_V2_KEY } = analytics;
   const scripts: IScripts = [];
   if (baidu) {
     scripts.push({
       content: 'var _hmt = _hmt || [];',
     });
   }
   if (api.env !== 'development') {
     if (baidu) {
       scripts.push({
         content: baiduTpl(baidu),
       });
     }
     if (ga) {
       scripts.push({
         content: gaTpl(ga),
       });
     }
     if (ga_v2) {
       scripts.push(
         {
           async: true,
           src: `//www.googletagmanager.com/gtag/js?id=${ga_v2}`,
         },
         {
           content: gaV2Tpl(ga_v2),
         },
       );
     }
   }
   return scripts.filter(Boolean);
 });

使用

// .umirc.ts

import { defineConfig } from 'umi';

export default defineConfig({
  // 这里是本地的地址,真实使用应该是'@umijs/plugins/dist/analytics',记得安装pnpm add -D @umijs/plugins,  用@umi/max就不用了,因为已经内置
plugins: ['../../packages/create-plugin/dist/cjs/analytics'],
analytics: {
  baidu: 'test444',
},
});

image.png

antd plugin 源码分析

以上的demo比较简单,看一个较为复杂的antd

api.describe

看到这样的类型定义,你可能会有些懵圈, 其实是用了zod, 比如

  • 简单demo
const mySchema = z.string();
// 解析
mySchema.parse("tuna"); // => "tuna"

const mySchema1 = z.boolean();
mySchema.parse(true); // => true
  • 原始值
// 原始值
z.string();
z.number();
z.bigint();
z.boolean();
z.date();

// 空类型
z.undefined();
z.null();
z.void(); // 接受null或undefined

// 全能类型
// 允许 any value
z.any();
z.unknown();

// never 类型
// 允许没有 values
z.never();
  • object
import { z } from "zod";

const User = z.object({
  username: z.string(),
});

User.parse({ username: "Ludwig" });

// 提取出推断的类型
type User = z.infer<typeof User>;
// { username: string }

  • union

用于合成 "OR" 类型

const stringOrNumber = z.union([z.string(), z.number()]);

stringOrNumber.parse("foo"); // 通过
stringOrNumber.parse(14); // 通过

还可以使用.or,但是如果多了的话,肯定是union好些

const stringOrNumber = z.string().or(z.number());
  • refine
// 自定义校验逻辑
.refine(validator: (data:T)=>any, params?: RefineParams)
const myString = z.string().refine((val) => val.length <= 255, {
  message: "String can't be more than 255 characters",
});
  • deepPartial

让对象的属性可选(深层次的)

const user = z.object({
  username: z.string(),
  location: z.object({
    latitude: z.number(),
    longitude: z.number(),
  }),
});

const deepPartialUser = user.deepPartial();

// {
//  username?: string | undefined,
// location?: {
//   latitude?: number | undefined;
 //   longitude?: number | undefined;
 // } | undefined
// }

  • record

Record 是一个意思,ts中Record<string,string>,这里省略了key的定义

const NumberCache = z.record(z.number());

type NumberCache = z.infer<typeof NumberCache>;
// => { [k: string]: number }

更多可以看zod官方文档

  api.describe({
   config: {
     schema({ zod }) {
       const commonSchema: Parameters<typeof zod.object>[0] = {
         // 这里比较简单,省略,可以去https://github.com/umijs/umi/blob/master/packages/plugins/src/antd.ts 看看完整的
       };
       
       const createZodRecordWithSpecifiedPartial = (
         partial: Parameters<typeof zod.object>[0],
       ) => {
         const keys = Object.keys(partial);
         return zod.union([
           zod.object(partial),
           zod.record(zod.any()).refine((obj) => {
             return !keys.some((key) => key in obj);
           }),
         ]);
       };
       
       const createV5Schema = () => {
         // Reason: https://github.com/umijs/umi/pull/11924
         // Refer:  https://github.com/ant-design/ant-design/blob/master/components/theme/interface/components.ts
         const componentNameSchema = zod.string().refine(
           (value) => {
             const firstLetter = value.slice(0, 1);
             return firstLetter === firstLetter.toUpperCase(); // first letter is uppercase
           },
           {
             message:
               'theme.components.[componentName] needs to be in PascalCase, e.g. theme.components.Button',
           },
         );
         
         // themeSchema
         // inherit,
         // algorithm,
         // token,
         // compoents: { 
         //   Layout: { 
         //     itemBg: 'red',
         //     ....
         //   },
         //   // other Component
         // }
         // https://ant.design/docs/react/customize-theme#theme
         const themeSchema = createZodRecordWithSpecifiedPartial({
           components: zod.record(componentNameSchema, zod.record(zod.any())),
         });
         
       // configProvider,
       // prefixCls,
       // ...
       // ...themeSchema
       // https://ant.design/components/config-provider
         const configProvider = createZodRecordWithSpecifiedPartial({
           theme: themeSchema,
         });

         return zod
           .object({
             ...commonSchema,
             theme: themeSchema.describe('Shortcut of `configProvider.theme`'),
             appConfig: zod
               .record(zod.any())
               .describe('Only >= antd@5.1.0 is supported'),
             momentPicker: zod
               .boolean()
               .describe('DatePicker & Calendar use moment version'),
             styleProvider: zod.record(zod.any()),
             configProvider,
           })
           .deepPartial();
       };
       
       const createV4Schema = () => {
         return zod
           .object({
             ...commonSchema,
             configProvider: zod.record(zod.any()),
           })
           .deepPartial();
       };
       if (isV5) {
         return createV5Schema();
       }
       if (isV4) {
         return createV4Schema();
       }
       return zod.object({});
     },
   },
   enableBy({ userConfig }) {
     // 由于本插件有 api.modifyConfig 的调用,以及 Umi 框架的限制
     // 在其他插件中通过 api.modifyDefaultConfig 设置 antd 并不能让 api.modifyConfig 生效
     // 所以这里通过环境变量来判断是否启用
     return process.env.UMI_PLUGIN_ANTD_ENABLE || userConfig.antd;
   },
 });

如此看来声明这块就非常好理解了

api.modifyAppData

修改 app 元数据。传入的 fn 接收 appData 并且返回它。

  api.modifyAppData((memo) => {
    checkPkgPath();
    const version = require(`${pkgPath}/package.json`).version;
    memo.antd = {
      pkgPath,
      version,
    };
    return memo;
  });

image.png

api.modifyConfig

修改配置,相较于用户的配置,这份是最终传给 Umi 使用的配置。传入的 fn 接收 config 作为第一个参数,并且返回它。另外 fn 可以接收 { paths } 作为第二个参数。paths 保存了 Umi 的各个路径。

针对用户传入的参数做处理

  api.modifyConfig((memo) => {
    checkPkgPath();

    let antd = memo.antd || {};
    // defaultConfig 的取值在 config 之后,所以改用环境变量传默认值
    if (process.env.UMI_PLUGIN_ANTD_ENABLE) {
      const { defaultConfig } = JSON.parse(process.env.UMI_PLUGIN_ANTD_ENABLE);
      // 通过环境变量启用时,保持 memo.antd 与局部变量 antd 的引用关系,方便后续修改
      memo.antd = antd = Object.assign(defaultConfig, antd);
    }

    // antd import
    memo.alias.antd = pkgPath;

    // antd 5 里面没有变量了,less 跑不起来。注入一份变量至少能跑起来
    if (isV5) {
      const theme = require('@ant-design/antd-theme-variable');
      memo.theme = {
        ...theme,
        ...memo.theme,
      };
      if (memo.antd?.import) {
        const errorMessage = `Can't set antd.import while using antd5 (${antdVersion})`;

        api.logger.fatal(
          'please change config antd.import to false, then start server again',
        );

        throw Error(errorMessage);
      }
    }

    // 只有 antd@4 才需要将 compact 和 dark 传入 less 变量
    if (isV4) {
      if (antd.dark || antd.compact) {
        const { getThemeVariables } = require('antd/dist/theme');
        memo.theme = {
          ...getThemeVariables(antd),
          ...memo.theme,
        };
      }

      memo.theme = {
        'root-entry-name': 'default',
        ...memo.theme,
      };
    }

    // allow use `antd.theme` as the shortcut of `antd.configProvider.theme`
    if (antd.theme) {
      assert(isV5, `antd.theme is only valid when antd is 5`);
      antd.configProvider ??= {};
      // priority: antd.theme > antd.configProvider.theme
      // 果然这里做了合并
      antd.configProvider.theme = deepmerge(
        antd.configProvider.theme || {},
        antd.theme,
      );

      // https://github.com/umijs/umi/issues/11156
      assert(
        !antd.configProvider.theme.algorithm,
        `The 'algorithm' option only available for runtime config, please move it to the runtime plugin, see: https://umijs.org/docs/max/antd#运行时配置`,
      );
    }

    if (antd.appConfig) {
      if (!appComponentAvailable) {
        delete antd.appConfig;
        api.logger.warn(
          `antd.appConfig is only available in version 5.1.0 and above, but you are using version ${antdVersion}`,
        );
      } else if (
        !appConfigAvailable &&
        Object.keys(antd.appConfig).length > 0
      ) {
        api.logger.warn(
          `versions [5.1.0 ~ 5.3.0) only allows antd.appConfig to be set to \`{}\``,
        );
      }
    }

    // 如果使用静态主题配置,需要搭配 ConfigProvider ,否则无效,我们自动开启它
    if (antd.dark || antd.compact) {
      antd.configProvider ??= {};
    }

    return memo;
  });

api.chainWebpack

通过 webpack-chain 的方式修改 webpack 配置。传入一个fn,该 fn 不需要返回值。它将接收两个参数:

  • memo 对应 webpack-chain 的 config
  • args:{ webpack, env } arg.webpack 是 webpack 实例, args.env 代表当前的运行环境。
  // Webpack
   const day2MomentAvailable = semver.gte(antdVersion, '5.0.0');
    
  api.chainWebpack((memo) => {
    if (api.config.antd.momentPicker) {
    // 如果是5.0.0 的版本,则注入antd-moment-webpack-plugin插件
      if (day2MomentAvailable) {
        memo.plugin('antd-moment-webpack-plugin').use(AntdMomentWebpackPlugin);
      } else {
        api.logger.warn(
          `MomentPicker is only available in version 5.0.0 and above, but you are using version ${antdVersion}`,
        );
      }
    }
    return memo;
  });

api.addExtraBabelPlugins

添加额外的 Babel 插件。 传入的 fn 不需要参数,且需要返回一个 Babel 插件或插件数组。

  // 添加 babel-plugin-import
  // https://github.com/umijs/babel-plugin-import umi 社区自己写的插件,按需加载,有意思
  
  api.addExtraBabelPlugins(() => {
    const style = api.config.antd.style || 'less';

    if (api.config.antd.import && !api.appData.vite) {
      return [
        [
          require.resolve('babel-plugin-import'),
          {
            libraryName: 'antd',
            libraryDirectory: 'es',
            ...(isV5 ? {} : { style: style === 'less' || 'css' }),
          },
          'antd',
        ],
      ];
    }

    return [];
  });

api.onGenerateFiles

生成临时文件时,随着文件变化会频繁触发,有缓存。 传入的 fn 接收的参数接口如下:

args: { isFirstTime?: boolean; files?: { event: string; path: string; } | null; }
// antd config provider & app component
  api.onGenerateFiles(() => {
   
  // 省略代码
    api.writeTmpFile({
      path: 'types.d.ts',
      context: {
        withConfigProvider,
        withAppConfig,
      },
      tplPath: winPath(join(ANTD_TEMPLATES_DIR, 'types.d.ts.tpl')),
    });

    api.writeTmpFile({
      path: RUNTIME_TYPE_FILE_NAME,
      content: `
import type { RuntimeAntdConfig } from './types.d';
export type IRuntimeConfig = {
  antd?: RuntimeAntdConfig
};
      `,
    });
   // 拥有 `ConfigProvider` 时,我们默认提供修改 antd 全局配置的便捷方法(仅限 antd 5)
   // 只要antd 5 才会在写入index.tsx 和 context.tsx
    if (antdConfigSetter) {
      api.writeTmpFile({
        path: 'index.tsx',
        content: `import React from 'react';
import { AntdConfigContext, AntdConfigContextSetter } from './context';

export function useAntdConfig() {
  return React.useContext(AntdConfigContext);
}

export function useAntdConfigSetter() {
  return React.useContext(AntdConfigContextSetter);
}`,
      });
      api.writeTmpFile({
        path: 'context.tsx',
        content: `import React from 'react';
import type { ConfigProviderProps } from 'antd/es/config-provider';

export const AntdConfigContext = React.createContext<ConfigProviderProps>(null!);
export const AntdConfigContextSetter = React.createContext<React.Dispatch<React.SetStateAction<ConfigProviderProps>>>(
  () => {
    console.error(\`The 'useAntdConfigSetter()' method depends on the antd 'ConfigProvider', requires one of 'antd.configProvider' / 'antd.dark' / 'antd.compact' to be enabled.\`);
  }
);
`,
      });
    }
  });

runtime.ts.tpl 部分片段

const getAntdConfig = () => {
  if(!cacheAntdConfig){
    cacheAntdConfig = getPluginManager().applyPlugins({
      key: 'antd',
      type: ApplyPluginsType.modify,
      initialValue: {
  {{#configProvider}}
        ...{{{configProvider}}},
  {{/configProvider}}
  {{#appConfig}}
        appConfig: {{{appConfig}}},
  {{/appConfig}}
      },
    });
  }
  return cacheAntdConfig;
}

生成模板后

const getAntdConfig = () => {
  if(!cacheAntdConfig){
    cacheAntdConfig = getPluginManager().applyPlugins({
      key: 'antd',
      type: ApplyPluginsType.modify,
      initialValue: {
        ...{"theme":{"token":{"colorPrimary":"#1DA57A"}}},
        appConfig: {"message":{"maxCount":3}},
      },
    });
  }
  return cacheAntdConfig;
}

image.png

api.addRuntimePlugin

添加运行时插件,传入的 fn 不需要参数,返回 string ,表示插件的路径。

// path: /Users/username/Documents/com-project/umi/examples/with-antd-5/.umi/plugin-antd/runtime.tsx

 api.addRuntimePlugin(() => {
    if (
      api.config.antd.styleProvider ||
      api.config.antd.configProvider ||
      (appComponentAvailable && api.config.antd.appConfig)
    ) {
      return [withTmpPath({ api, path: 'runtime.tsx' })];
    }
    return [];
  });

api.addEntryImportsAhead

在入口文件中添加 import 语句 (import 最前面)。传入的 fn 不需要参数,其需要返回一个 {source: string, specifier?: string} 或其数组。

  api.addEntryImportsAhead(() => {
    const style = api.config.antd.style || 'less';
    const imports: Awaited<
      ReturnType<Parameters<IApi['addEntryImportsAhead']>[0]['fn']>
    > = [];

    if (isV5) {
      // import antd@5 reset style
      imports.push({ source: 'antd/dist/reset.css' });
    } else if (!api.config.antd.import || api.appData.vite) {
      // import antd@4 style if antd.import is not configured
      imports.push({
        source: style === 'less' ? 'antd/dist/antd.less' : 'antd/dist/antd.css',
      });
    }

    return imports;
  });

.umi/umi.ts下面可以看到

image.png

这样用户在注入插件后, 用起来特别简单

使用

// umirc.ts
  plugins: ['@umijs/plugins/dist/antd'],
  antd: {
    // valid for antd5.0 only
    theme: {
      token: {
        colorPrimary: 'red',
      },
    },
    styleProvider: {
      hashPriority: 'high',
      legacyTransformer: true,
    },
    // dark: true,
    compact: true,
    /**
     * antd@5.1.0 ~ 5.2.3 仅支持 appConfig: {}, 来启用 <App /> 组件;
     * antd@5.3.0 及以上才支持 appConfig: { // ... } 来添加更多 App 配置项;
     */
    appConfig: {
      message: {
        maxCount: 3,
      },
    },
  },

暂时没有想到,自己要写一个什么样的插件,不过通过这些源码的阅读, 对 plugin的各种api 有了比较深的印象