|
前言umi是一个插件化的企业级前端应用框架,在开发中后台项目中应用颇广,确实带来了许多便利。借着这个契机,便有了我们接下来的“umi3源码解析”系列的分享,初衷很简单就是从源码层面上帮助大家深入认知umi这个框架,能够更得心应手的使用它,学习源码中的设计思想提升自身。该系列的大纲如下:开辟鸿蒙,今天要解析的就是第一part,内容包括以下两个部分:邂逅umi命令,看看umidev时都做了什么?初遇插件化,了解源码中核心的Service类初始化的过程。本次使用源码版本为 3.5.21,https://github.com/umijs/umi.git地址放在这里了,接下来的每一块代码笔者都贴心的为大家注释了在源码中的位置,先clone再食用更香哟!邂逅umi命令该部分在源码中的路径为:packages/umi首先是第一部分umi命令,umi脚手架为我们提供了umi这个命令,当我们创建完一个umi项目并安装完相关依赖之后,通过yarnstart启动该项目时,执行的命令就是umidev那么在umi命令运行期间都发生了什么呢,先让我们来看一下完整的流程,如下图:接下来我们对其几个重点的步骤进行解析,首先就是对于我们在命令行输入的umi命令进行处理。处理命令行参数// packages/umi/src/cli.tsconst args = yParser(process.argv.slice(2), { alias: { version: ['v'], help: ['h'], }, boolean: ['version'],});if (args.version & !args._[0]) { args._[0] = 'version'; const local = existsSync(join(__dirname, '../.local')) ? chalk.cyan('@local') : ''; console.log(`umi@${require('../package.json').version}${local}`);} else if (!args._[0]) { args._[0] = 'help';}解析命令行参数所使用的yParser方法是基于yargs-parser封装,该方法的两个入参分别是进程的可执行文件的绝对路径和正在执行的JS文件的路径。解析结果如下:// 输入 umi dev 经 yargs-parser 解析后为:// args={// _: ["dev"],// }在解析命令行参数后,对version和help参数进行了特殊处理:如果args中有version字段,并且args._中没有值,将执行version命令,并从package.json中获得version的值并打印如果没有version字段,args._中也没有值,将执行help命令总的来说就是,如果只输入umi实际会执行umihelp展示umi命令的使用指南,如果输入umi--version会输出依赖的版本,如果执行umidev那就是接下来的步骤了。❝提问:您知道输入umi--versiondev会发什么吗?运行umidev// packages/umi/src/cli.tsconst child = fork({ scriptPath: require.resolve('./forkedDev'),});process.on('SIGINT', () => { child.kill('SIGINT'); process.exit(0);});// packages/umi/src/utils/fork.tsif (CURRENT_PORT) { process.env.PORT = CURRENT_PORT;}const child = fork(scriptPath, process.argv.slice(2), { execArgv });child.on('message', (data: any) => { const type = (data & data.type) || null; if (type === 'RESTART') { child.kill(); start({ scriptPath }); } else if (type === 'UPDATE_PORT') { // set current used port CURRENT_PORT = data.port as number; } process.send?.(data);});本地开发时,大部分脚手架都会采用开启一个新的线程来启动项目,umi脚手架也是如此。这里的fork方法是基于node中child_process.fork()方法的封装,主要做了以下三件事:确定端口号,使用命令行指定的端口号或默认的8080,如果该端口号已被占用则prot+=1开启子进程,该子进程独立于父进程,两者之间建立IPC通信通道进行消息传递处理通信,主要监听了RESTART重启和UPDATE_PORT更新端口号事件接下来看一下在子进程中运行的forkedDev.ts都做了什么。// packages/umi/src/forkedDev.ts(async () => { try { // 1、设置 NODE_ENV 为 development process.env.NODE_ENV = 'development'; // 2、Init webpack version determination and require hook initWebpack(); // 3、实例化 Service 类,执行 run 方法 const service = new Service({ cwd: getCwd(), // umi 项目的根路径 pkg: getPkg(process.cwd()), // 项目的 package.json 文件的路径 }); await service.run({ name: 'dev', args, }); // 4、父子进程通信 let closed = false; process.once('SIGINT', () => onSignal('SIGINT')); process.once('SIGQUIT', () => onSignal('SIGQUIT')); process.once('SIGTERM', () => onSignal('SIGTERM')); function onSignal(signal: string) { if (closed) return; closed = true; // 退出时 触发插件中的onExit事件 service.applyPlugins({ key: 'onExit', type: service.ApplyPluginsType.event, args: { signal, }, }); process.exit(0); } } catch (e: any) { process.exit(1); }})();设置process.env.NODE_ENV的值initWebpack(接下来解析)实例化Service并run(第二part的内容)处理父子进程通信,当父进程监听到SIGINT、SIGTERM等终止进程的信号,也通知到子进程进行终止;子进程退出时触发插件中的onExit事件initWebpack// packages/umi/src/initWebpack.tsconst haveWebpack5 = (configContent.includes('webpack5:') & !configContent.includes('// webpack5:') & !configContent.includes('//webpack5:')) || (configContent.includes('mfsu:') & !configContent.includes('// mfsu:') & !configContent.includes('//mfsu:'));if (haveWebpack5 || process.env.USE_WEBPACK_5) { process.env.USE_WEBPACK_5 = '1'; init(true);} else { init();}initRequreHook();这一步功能是检查用户配置确定初始化webpack的版本。读取默认配置文件.umirc和config/config中的配置,如果其中有webpack5或 mfsu等相关配置,umi就会使用webpack5进行初始化,否则就使用webpack4进行初始化。这里的mfsu是webpack5的模块联邦相关配置,umi在3.5版本时已经进行了支持。初遇插件化该部分在源码中的路径为:packages/core/src/Service说起umi框架,最先让人想到的就是插件化,这也是框架的核心,该部分实现的核心源码就是Service类,接下来我们就来看看Service类的实例化和init()的过程中发生了什么,可以称之为插件化实现的开端,该部分的大致流程如下该流程图中前四步,都是在Service类实例化的过程中完成的,接下来让我们走进Service类。Service类的实例化// packages/core/src/Service/Service.tsexport default class Service extends EventEmitter { constructor(opts: IServiceOpts) { super(); this.cwd = opts.cwd || process.cwd(); // 当前工作目录 // repoDir should be the root dir of repo this.pkg = opts.pkg || this.resolvePackage(); // package.json this.env = opts.env || process.env.NODE_ENV; // 环境变量 // 在解析config之前注册babel this.babelRegister = new BabelRegister(); // 通过dotenv将环境变量中的变量从 .env 或 .env.local 文件加载到 process.env 中 this.loadEnv(); // 1、get user config const configFiles = opts.configFiles; this.configInstance = new Config({ cwd: this.cwd, service: this, localConfig: this.env === 'development', configFiles }); this.userConfig = this.configInstance.getUserConfig(); // 2、get paths this.paths = getPaths({ cwd: this.cwd, config: this.userConfig!, env: this.env, }); // 3、get presets and plugins this.initialPresets = resolvePresets({ ...baseOpts, presets: opts.presets || [], userConfigPresets: this.userConfig.presets || [], }); this.initialPlugins = resolvePlugins({ ...baseOpts, plugins: opts.plugins || [], userConfigPlugins: this.userConfig.plugins || [], }); }}Service类继承自EventEmitter用于实现自定义事件。在Service类实例化的过程中除了初始化成员变量外主要做了以下三件事:1、解析配置文件// packages/core/src/Config/Config.tsconst DEFAULT_CONFIG_FILES = [ // 默认配置文件 '.umirc.ts', '.umirc.js', 'config/config.ts', 'config/config.js',];// ...if (Array.isArray(opts.configFiles)) { // 配置的优先读取 this.configFiles = lodash.uniq(opts.configFiles.concat(this.configFiles));}//...getUserConfig() { // 1、找到 configFiles 中的第一个文件 const configFile = this.getConfigFile(); this.configFile = configFile; // 潜在问题:.local 和 .env 的配置必须有 configFile 才有效 if (configFile) { let envConfigFile; if (process.env.UMI_ENV) { // 1.根据 UMI_ENV 添加后缀 eg: .umirc.ts --> .umirc.cloud.ts const envConfigFileName = this.addAffix( configFile, process.env.UMI_ENV, ); // 2.去掉后缀 eg: .umirc.cloud.ts --> .umirc.cloud const fileNameWithoutExt = envConfigFileName.replace( extname(envConfigFileName), '', ); // 3.找到该环境下对应的配置文件 eg: .umirc.cloud.[ts|tsx|js|jsx] envConfigFile = getFile({ base: this.cwd, fileNameWithoutExt, type: 'javascript', })?.filename; } const files = [ configFile, // eg: .umirc.ts envConfigFile, // eg: .umirc.cloud.ts this.localConfig & this.addAffix(configFile, 'local'), // eg: .umirc.local.ts ] .filter((f): f is string => !!f) .map((f) => join(this.cwd, f)) // 转为绝对路径 .filter((f) => existsSync(f)); // clear require cache and set babel register const requireDeps = files.reduce((memo: string[], file) => { memo = memo.concat(parseRequireDeps(file)); // 递归解析依赖 return memo; }, []); // 删除对象中的键值 require.cache[cachePath],下一次 require 将重新加载模块 requireDeps.forEach(cleanRequireCache); this.service.babelRegister.setOnlyMap({ key: 'config', value: requireDeps, }); // require config and merge return this.mergeConfig(...this.requireConfigs(files)); } else { return {}; }}细品源码,可以看出umi读取配置文件的优先级:自定义配置文件 >.umirc>config/config,后续根据UMI_ENV尝试获取对应的配置文件,development模式下还会使用local配置,不同环境下的配置文件也是有优先级的例如:.umirc.local.ts>.umirc.cloud.ts>.umirc.ts由于配置文件中可能require其他配置,这里通过parseRequireDeps方法进行递归处理。在解析出所有的配置文件后,会通过cleanRequireCache方法清除requeire缓存,这样可以保证在接下来合并配置时的引入是实时的。2、获取相关绝对路径// packages/core/src/Service/getPaths.tsexport default function getServicePaths({ cwd, config, env,}: { cwd: string; config: any; env?: string;}): IServicePaths { let absSrcPath = cwd; if (isDirectoryAndExist(join(cwd, 'src'))) { absSrcPath = join(cwd, 'src'); } const absPagesPath = config.singular ? join(absSrcPath, 'page') : join(absSrcPath, 'pages'); const tmpDir = ['.umi', env !== 'development' & env] .filter(Boolean) .join('-'); return normalizeWithWinPath({ cwd, absNodeModulesPath: join(cwd, 'node_modules'), absOutputPath: join(cwd, config.outputPath || './dist'), absSrcPath, // src absPagesPath, // pages absTmpPath: join(absSrcPath, tmpDir), });}这一步主要获取项目目录结构中node_modules、dist、src、pages等文件夹的绝对路径。如果用户在配置文件中配置了singular为true,那么页面文件夹路径就是src/page,默认是src/pages3、收集preset和plugin以对象形式描述在umi中“万物皆插件”,preset是对于插件的描述,可以理解为“插件集”,是为了方便对插件的管理。例如:@umijs/preset-react就是一个针对react应用的插件集,其中包括了plugin-access权限管理、plugin-antdantdUI组件等。// packages/core/src/Service/Service.tsthis.initialPresets = resolvePresets({ ...baseOpts, presets: opts.presets || [], userConfigPresets: this.userConfig.presets || [],});this.initialPlugins = resolvePlugins({ ...baseOpts, plugins: opts.plugins || [], userConfigPlugins: this.userConfig.plugins || [],});在收集preset和plugin时,首先调用了resolvePresets方法,其中做了以下处理:3.1、调用getPluginsOrPresets方法,进一步收集preset和plugin并合并// packages/core/src/Service/utils/pluginUtils.tsgetPluginsOrPresets(type: luginType, opts: IOpts): string[] { const upperCaseType = type.toUpperCase(); return [ // opts ...((opts[type === luginType.preset ? 'presets' : 'plugins'] as any) || []), // env ...(process.env[`UMI_${upperCaseType}S`] || '').split(',').filter(Boolean), // dependencies ...Object.keys(opts.pkg.devDependencies || {}) .concat(Object.keys(opts.pkg.dependencies || {})) .filter(isPluginOrPreset.bind(null, type)), // user config ...((opts[ type === luginType.preset ? 'userConfigPresets' : 'userConfigPlugins' ] as any) || []), ].map((path) => { return resolve.sync(path, { basedir: opts.cwd, extensions: ['.js', '.ts'], }); });}这里可以看出收集preset和plugin的来源主要有四个:实例化Service时的入参process.env中指定的UMI_PRESETS或UMI_PLUGINSpackage.json中dependencies和devDependencies配置的,需要命名规则符合 /^(@umijs\/|umi-)preset-/这个正则解析配置文件中的,即入参中的userConfigPresets或userConfigPresets3.2、调用pathToObj方法:将收集的plugin或preset以对象的形式输出// packages/core/src/Service/utils/pluginUtils.ts// pathToObj 的返回结果return { id, key, path: winPath(path), apply() { // use function to delay require try { const ret = require(path); // use the default member for es modules return compatESModuleRequire(ret); } catch (e) { throw new Error(`Register ${type} ${path} failed, since ${e.message}`); } }, defaultConfig: null,};umi官网中提到过:每个插件都会对应一个id和一个key,id是路径的简写,key是进一步简化后用于配置的唯一值。便是在这一步进行的处理形式如下:{ id: './node_modules/umi/lib/plugins/umiAlias', key: 'umiAlias', path: '项目地址/node_modules/umi/lib/plugins/umiAlias.js', apply: ..., defaultConfig: null}❝思考:为什么要将插件以对象的形式进行描述?有什么好处?执行run方法,初始化插件在Service类实例化完毕后,会立马调用run方法,run()执行的第一步就是执行init方法,init()方法的功能就是完成插件的初始化,主要操作如下:遍历initialPresets并init合并initpresets过程中得到的plugin和initialPlugins遍历合并后的plugins并init这里的initialPresets和initialPlugins就是上一步收集preset和plugin得到的结果,在这一步要对其逐一的init,接下来我们看一下init的过程中做了什么。Initplugin// packages/core/src/Service/Service.tsasync initPreset(preset: IPreset) { const { id, key, apply } = preset; preset.isPreset = true; const api = this.getPluginAPI({ id, key, service: this }); // register before apply this.registerPlugin(preset); const { presets, plugins, ...defaultConfigs } = await this.applyAPI({ api, apply, }); // register extra presets and plugins if (presets) { // 插到最前面,下个 while 循环优先执行 this._extraPresets.splice( 0, 0, ...presets.map((path: string) => { return pathToObj({ type: luginType.preset, path, cwd: this.cwd, }); }), ); } // 深度优先 const extraPresets = lodash.clone(this._extraPresets); this._extraPresets = []; while (extraPresets.length) { await this.initPreset(extraPresets.shift()!); } if (plugins) { this._extraPlugins.push( ...plugins.map((path: string) => { return pathToObj({ type: luginType.plugin, path, cwd: this.cwd, }); }), ); }}// initPluginasync initPlugin(plugin: IPlugin) { const { id, key, apply } = plugin; const api = this.getPluginAPI({ id, key, service: this }); // register before apply this.registerPlugin(plugin); await this.applyAPI({ api, apply });}这段代码主要做了以下几件事情:getPluginAPI方法:newPluginAPI时传入了Service实例,通过pluginAPI实例中的registerMethod方法将register方法添加到Service实例的pluginMethods中,后续返回pluginAPI的代理,以动态获取最新的register方法,以实现边注册边使用。// packages/core/src/Service/Service.tsgetPluginAPI(opts: any) { const pluginAPI = new luginAPI(opts); // register built-in methods [ 'onPluginReady', 'modifyPaths', 'onStart', 'modifyDefaultConfig', 'modifyConfig', ].forEach((name) => { pluginAPI.registerMethod({ name, exitsError: false }); }); return new roxy(pluginAPI, { get: (target, prop: string) => { // 由于 pluginMethods 需要在 register 阶段可用 // 必须通过 proxy 的方式动态获取最新,以实现边注册边使用的效果 if (this.pluginMethods[prop]) return this.pluginMethods[prop]; if ( [ 'applyPlugins', 'ApplyPluginsType', 'EnableBy', 'ConfigChangeType', 'babelRegister', 'stage', 'ServiceStage', 'paths', 'cwd', 'pkg', 'userConfig', 'config', 'env', 'args', 'hasPlugins', 'hasPresets', ].includes(prop) ) { return typeof this[prop] === 'function' ? this[prop].bind(this) : this[prop]; } return target[prop]; }, });}在register方法执行时会将该插件的hooks加入到Service实例的hooksByPluginId中// hookshooksByPluginId: { [id: string]: IHook[];} = {};export interface IHook { key: string; // eg: onPluginReady fn: Function; pluginId?: string; // plugin 描述对象中的 id before?: string; stage?: number;}registerPlugin是以plugin的id为键值将其添加到this.plugins中applyAPI就是调用preset对象中的apply方法获得preset文件的返回值,该返回值是一个方法,执行该方法,入参是api。如果applyAPI返回了presets会将该presets插入到_extraPresets遍历队列的最前方,保证在下一次优先执行// packages/core/src/Service/Service.tsasync applyAPI(opts: { apply: Function; api: luginAPI }) { let ret = opts.apply()(opts.api); if (isPromise(ret)) { ret = await ret; } return ret || {};}由于presets支持嵌套,所以对_extraPresets进行了递归处理对于插件hooks的进一步处理// packages/core/src/Service/Service.tsObject.keys(this.hooksByPluginId).forEach((id) => { const hooks = this.hooksByPluginId[id]; hooks.forEach((hook) => { const { key } = hook; hook.pluginId = id; this.hooks[key] = (this.hooks[key] || []).concat(hook); });});//hooks: { [key: string]: IHook[];} = {};经过上一步initplugin得到的hooksByPluginId中是pluginid--该插件各个阶段的hook这样的键值对在这里进行转换,结果为hooks中的hookname--该阶段所有插件的hook,比如:键是onPluginReady值对应的就是各个插件的onPluginReady钩子,这样在后续就可以一次性执行所有插件的钩子。❝从上边initPlugin和处理hooks的过程中不难看出,以对象的形式描述插件,让umi对于插件的处理变得非常灵活。总结经过以上部分的源码解析,大家肯定发现了。不管是提供umi命令,支持多样化的配置(比如:收集package.json中的依赖进行插件注册,是pages还是page?),还是为我们提供preset+plugin的插件配置模式,umi为了节省使用者的开发效率真的做了很多事情,同时还非常注重性能(对插件hooks的处理中可见一斑)。umi的理念之一就是“约定大于配置”,按部就班的搭建项目引用插件,使用者只需要把重心放在业务逻辑上即可,多是一件美事啊。OK,今天的源码解析就到这里为止喽,不知各位感觉如何呢?如果还是感到疑惑可以留言讨论,若是让您有所收获的话那就欢迎大家点赞并关注后续该系列的更新哟!
|
|