@umijs/core源码解读

1,068 阅读7分钟

当前umi版本为4.0.0,由于本人水平有限,如有错误望指正。

学习umi使用可以前往Umi 4 开发实战小册

前言

下载项目后,在根目录执行pnpm install,调试时执行pnpm dev,然后就可以运行examples目录下的测试项目调试了

应用代表用户项目

阅读本文前,期望您已经了解了如下几个插件

esbuild

这个插件都比较熟悉,不作介绍

joi

配置校验工具,官网

pirates

用于处理node加载(require) es6、ts文件

详情可以看阿宝哥文章如何为 Node.js 的 require 函数添加钩子?

在工程中如下使用,就可以在

// /packages/utils/src/register.ts

let registered = false // 不重复添加
// 用于解析完后移除addHook
let revert:() => void = () => {}

const COMPILE_EXTS = ['.ts', '.tsx', '.js', '.jsx'];
const HOOK_EXTS = [...COMPILE_EXTS, '.mjs'];

// implementor为解析方式,一般用esbuild
// exts需要处理的后缀名集合
export function register(opts: { implementor: any; exts?: string[] }) {
  files = [];
  if (!registered) {
    revert = addHook(
    // 这里基本上就是返回esbuild编译后的内容
      (code, filename) =>
        transform({ code, filename, implementor: opts.implementor }),
      {
        ext: opts.exts || HOOK_EXTS,
        ignoreNodeModules: true,
      },
    );
    registered = true;
  }
}

tapable

一套优雅的发布订阅插件模式

推荐看Tapable,看这一篇就够了

源码中Service类(/packages/core/src/service/service.ts)的applyPlugins方法就应用了其事件流程

chokidar

chokidar.watch(paths, [options])

用于监听文件变化,解决了fs.watchfs.watchFile的不足,并且 API 简单易用且监听效率更加高效

vite、webpack-dev-server、vscode等多个有名的库都使用了它

scripts

这个umi-scripts指令是umi内部的指令,bin文件中执行的逻辑大概就是运行umi-scripts [指令],就可以执行scripts下文件名的ts脚本了。

例如umi-scripts turbo --cmd build就是执行scripts/turbo.ts

umi

内容不是很多,稍微提一下,执行顺序:从上至下依次执行

/cli/cli.ts

当我们的umi项目执行umi [指令]时,会执行文件下的run函数

这里校验了node版本、设置了一些环境信息,然后如果是dev,则执行dev函数,否则直接运行new Service().run2(),由于dev中也会创建Service,我们跳过这块

/cli/dev.ts、/cli/fork.ts

开启进程去执行forkedDev

/cli/forkedDev.ts

创建服务,并运行,并在进程关闭前调用onExit方法出发插件的一些行为

service.applyPlugins({
  key: 'onExit',
  args: {
    signal,
  },
})

/service/service.ts

在构造函数中初始化了默认preset:'@umijs/preset-umi',默认的插件join(cwd, 'plugin.ts')(即项目根目录下允许创建自定义插件),默认配置文件位置DEFAULT_CONFIG_FILES,应用的启动目录env.APP_ROOT || process.cwd()

run2为run的前置函数,目前适配了版本帮助指令的简写

@umijs/core

该库大概做了以下几件事

  • Config读取配置文件内容,用户配置>系统配置
  • getPaths获取用户项目路径
  • 初始化插件,preset也是插件的一种
  • 读取插件的配置信息,schema、default、onChange
  • applyPlugins广播核心插件方法
    • modifyConfig处理schema用户配置
    • modifyDefaultConfig处理default用户配置
    • modifyPaths处理getPaths的路径
    • modifyAppData处理应用属性
    • onCheck启动应用前的最后检查
    • onStart通知应用即将启动
  • onStart后执行dev或build等的fn函数

执行run函数

首先获取pkg即应用的package.json内容,后续又可能获取其中安装的插件,用于自动加载插件,但我目前的版本没有开启

// dependencies
// ...Object.keys(opts.pkg.devDependencies || {})
//   .concat(Object.keys(opts.pkg.dependencies || {}))
//   .filter(Plugin.isPluginOrPreset.bind(null, type)),

然后构造Config

/config/config.ts Config

  • 构造函数:记录了cwd、环境类型(生产、开发、测试)、主配置文件位置(通过Config.getMainConfigFile函数遍历配置文件直到匹配上)
  • utils的getAbsFiles用于获取文件的绝对路径
  • getUserConfig:通过Config.getConfigFiles返回配置文件列表(为什么是列表,因为有.umirc.ts.umirc.dev.ts.umirc.local.ts),然后通过Config.getUserConfig获取配置文件的内容并合并当前环境下的所有配置
  • Config.getUserConfig:用到了之前提到的pirates库辅助获取配置文件内容,文件解析用的esbuild
  • getConfig:对getUserConfig进行了封装,添加了校验配置是否合法的逻辑
  • Config.validateConfig:校验内容是否合法,用到了之前提到的joi库,每一项配置都匹配一个schema(schema来源于插件的api.describe)
  • watch:使用chokidar.watch监听配置文件变化,并做了深度比对,回调文件变化的部分

/service/path.ts getPaths

然后调用getPaths获取部分绝对路径配置,其中包含src目录、page目录、node_modules目录、构建产物dist目录、自动生成的.umi文件目录位置等。既然都是单独获取,那么我们就可以通过插件修改,比如调整缓存文件.umi的位置、打包产物的位置

const paths = getPaths({
  cwd: this.cwd,
  env: this.env,
  prefix: this.opts.frameworkName || DEFAULT_FRAMEWORK_NAME,
});

/service/plugin.ts Plugin

随后调用Plugin.getPluginsAndPresets方法初始化插件

const { plugins, presets } = Plugin.getPluginsAndPresets({
  cwd: this.cwd,
  pkg,
  plugins: [require.resolve('./generatePlugin')].concat(
    this.opts.plugins || [], // 这里包含了应用根目录的plugin.ts插件
  ),
  presets: [require.resolve('./servicePlugin')].concat(
    this.opts.presets || [], // 这里在我们的流程里是preset-umi插件
  ),
  userConfig: this.userConfig,
  prefix, // 用于读取环境变量中注册的插件
});
  • getPluginsAndPresets:我们可以看到该函数中,读取了初始化传入的插件、环境变量中注册的插件、用户配置文件的插件。然后构造Plugin实例
  • 构造函数:初始化了插件的id(不可变)、key(key在后续可以修改),注册了apply函数用于返回插件的内容(这里又用到了pirates来读取)
  • merge:用于重设插件的key、config、enableBy。对注册schema和default有用

读取插件中的presets、plugins

这里类似于深度遍历presets中包含的presets。比如我每个项目都要注册相同的10个插件,但我不想一一注册,于是可以注册一个presets,返回一个插件集。preset-umi和max等其实就是一个插件集

while (presets.length) {
  await this.initPreset({
    preset: presets.shift()!,
    presets,
    plugins: presetPlugins,
  });
}

将其余非preset插件注册

plugins.unshift(...presetPlugins);
while (plugins.length) {
  await this.initPlugin({ plugin: plugins.shift()!, plugins });
}

这里涉及到两个函数initPreset、initPlugin,initPreset内部调用了initPlugin,initPlugin内部创建了PluginAPI,下面我们来看下PluginAPI

/service/pluginAPI.ts PluginAPI

该类的作用是暴露插件api,让我们自定义插件更加得心应手。

  • 构造函数:记录service和当前注册的插件,调整日志输出(log时自带插件前缀)
  • describe:插件中调用时会
    • 重置插件key、config、enableBy
    • schema传入库joi,配置校验
    • schema配置用于校验.umirc的每一项属性是否符合schema规则
  • registerCommand:插件中调用时用作添加指令,最后指令内容会添加到service.commands上,例如dev。alias参数是别名,如果有再重复注册即可,例如version的别名是v。
api.registerCommand({
  name: 'dev',
})
  • registerGenerator:注册微生成器,允许插件生成代码或配置等,绑定在service.generators
  • register:订阅hook到service.hooks上,会在事件广播时消费
  • registerMethod:在service.pluginMethods上注册全局函数,允许其它插件调用。如果只传了名称,则改名称的函数调整为注册函数,注册的函数将绑定到service.hooks上。用以下两个例子区分
api.registerMethod({name: 'a', fn() {}})

// 可以直接调用
api.a()
api.registerMethod({name: 'a'})

// 依赖收集
api.a(() => {console.log('1')})
api.a(() => {console.log('2')})

// 广播发布
api.applyPlugins({key: 'a'})
  • registerPresets/registerPlugins为插件添加插件,不会绑定到service上,暂时不知道作用
  • skipPlugins:设定需要跳过的插件,在hook执行时判断是否跳过hook
  • proxyPluginAPI:代理PluginAPI,用于暴露更多插件api给自定义插件使用。例如service.pluginMethods等

initPlugin方法

了解了PluginAPI,让我们回到initPlugin方法,它主要做了如下几件事

  • 将plugin绑定到PluginAPI对象上
  • 配置代理,暴露更多属性
  • 执行插件函数传入代理对象proxyPluginAPI,让插件可以做更多事情
  • 绑定插件到service.keyToPluginMap上,方便跳过等其它操作
  • 给调用方递归创建插件

回到run函数内,下面循环将api.describe执行后调整的插件配置给绑定到service上

for (const id of Object.keys(this.plugins)) {
  const { config, key } = this.plugins[id];
  if (config.schema) this.configSchemas[key] = config.schema;
  if (config.default !== undefined) {
    this.configDefaults[key] = config.default;
  }
  this.configOnChanges[key] = config.onChange || ConfigChangeType.reload;
}

至此,插件初始化及相关内容已完成

/service/servicePlugin.ts

插件通过registerMethod函数注册核心可以广播的事件

开始事件广播

上面提到过,允许我再复制一遍^_^

  • modifyConfig处理schema用户配置
  • modifyDefaultConfig处理default用户配置
  • modifyPaths处理getPaths的路径
  • modifyAppData处理应用属性
  • onCheck启动应用前的最后检查
  • onStart通知应用即将启动

最后执行如下代码,表示执行指令的fn函数

let ret = await command.fn({ args });

applyPlugins

分三种类型:新增、编辑、事件发布

通过异步串行瀑布钩子给每个依次钩子执行,并将结果返回给下一个钩子

/service/generatePlugin.ts

微生成器的启动指令

结语

至此@umijs/core大部分内容已解读完成,后续有时间再一一解读umi的其它内容