Taro cli流程和插件化机制实现原理
前言
自 2.2 开始,Taro 引入了插件化机制,目的是为了让开发者能够通过编写插件的方式来为 Taro 拓展更多功能或为自身业务定制个性化功能。
本文基于Taro3.4.2源码讲解
CLI流程
1.执行cli
命令,如npm run start
,实际上在package.json
中script
脚本列表中可以往下解读一直找到build:weapp
这条脚本所执行的对应具体指令信息,dev
模式下区别prod
模式只是多了一个--watch
热加载而已,只是区分了对应的env
环境,在webpack打包的时候分别预设了对应环境不同的打包配置,例如判断生产环境才会默认启用代码压缩等
2.那么这个taro指令是在哪定义的呢?taro在你全局安装的时候就已经配置到环境变量了,我们项目目录下去执行`package.json
中的script
脚本命令,它会在当前目录下去找node
脚本,找不到就向上级找,最终执行该脚本。
3.taro的核心指令源码都在taro/cli
下,常用的指令有init
(创建项目)、build
(构建项目)。启动命令入口在taro/cli/bin/taro
// @taro/cli/bin/taro #! /usr/bin/env node require('../dist/util').printPkgVersion() const CLI = require('../dist/cli').default new CLI().run()
4.启动后,CLI
实例先实例化了一个继承EventEmitter
的Kernel
核心类(ctx
),解析脚本命令参数后调用customCommand
方法,传入kernel
实例和所有项目参数相关。
// taro-cli/src/cli.ts // run const kernel = new Kernel({ appPath: this.appPath, presets: [ path.resolve(__dirname, '.', 'presets', 'index.js') ] }) let plugin // script 命令中的 --type参数 let platform = args.type const { publicPath, bundleOutput, sourcemapOutput, sourceMapUrl, sourcemapSourcesRoot, assetsDest } = args // 小程序插件开发, script: taro build --plugin weapp --watch customCommand('build', kernel, { _: args._, platform, plugin, isWatch: Boolean(args.watch), port: args.port, env: args.env, deviceType: args.platform, resetCache: !!args.resetCache, publicPath, bundleOutput, sourcemapOutput, sourceMapUrl, sourcemapSourcesRoot, assetsDest, qr: !!args.qr, blended: Boolean(args.blended), h: args.h })
5.customCommand
中将所有的参数整理后调用Kernel.run
,传入整理后的所有参数。
kernel.run({ name: command, opts: { _: args._, options, isHelp: args.h } })
6.接下去就是在Kernel
类中一系列项目初始化的工作流程,包括设置参数、初始化相关配置、执行内设的钩子函数、修改webpack等,Kernel
中的所有属性在插件开发中都可以通过ctx
访问,简略了部分代码,如下:
// taro-service/src/Kernel.ts async run (args: string | { name: string, opts?: any }) { // ... // 设置参数,前面cli.ts中传入的一些项目配置信息参数,例如isWatch等 this.setRunOpts(opts) // 重点:初始化相关配置 await this.init() // 注意:Kernel 的前两个生命周期钩子是 onReady 和 onStart,并没有执行操作,开发者在自己编写插件时可以注册对应的钩子 // 执行onStart钩子 await this.applyPlugins('onStart') // name: example: build... // 处理 --help 的日志输出 例如:taro build --help if (opts?.isHelp) { return this.runHelp(name) } // 获取平台配置 if (opts?.options?.platform) { opts.config = this.runWithPlatform(opts.options.platform) } // 执行钩子函数 modifyRunnerOpts // 作用:修改webpack参数,例如修改 H5 postcss options await this.applyPlugins({ name: 'modifyRunnerOpts', opts: { opts: opts?.config } }) // 执行传入的命令 await this.applyPlugins({ name, opts }) }
其中重点的初始化流程在Kernel.init
中。
插件主要流程
Kernel.init
流程如下:
async init () { this.debugger('init') // 初始化项目配置,也就是你config目录配置的那些 this.initConfig() // 初始化项目资源目录,例如:输出目录、依赖目录,src、config配置目录等,部分配置是在你项目的config/index.js中的config中配置的东西,如 // sourcePath和outputPath // https://taro-docs.jd.com/taro/docs/plugin 插件环境变量 this.initPaths() // 初始化预设和插件 this.initPresetsAndPlugins() // 注意:Kernel 的前两个生命周期钩子是 onReady 和 onStart,并没有执行操作,开发者在自己编写插件时可以注册对应的钩子 // 执行onReady钩子 await this.applyPlugins('onReady') }
插件环境变量
追溯文档给出的ctx
使用时可能会用到的主要环境变量实现原理,关于环境变量使用详情????文档地址
ctx.runOpts
获取当前执行命令所带的参数,例如命令 taro upload --remote xxx.xxx.xxx.xxx
,则 ctx.runOpts
值为:
{ _: ['upload'], options: { remote: 'xxx.xxx.xxx.xxx' }, isHelp: false }
runOpts
在taro-service/src/Kernel.ts
的run
方法初始化,早于Kernel.init
,因为runOpts
包含的命令参数在实例化Kernel
的时候就已经解析了,只是在run
里面给当前上下文(Kernel
)赋值保存起来,也就是调用时的ctx
。源码如下:
// taro-service/src/Kernel.ts this.setRunOpts(opts) // 保存当前执行命令所带的参数 setRunOpts (opts) { this.runOpts = opts }
ctx.helper
为包 @tarojs/helper
的快捷使用方式,包含其所有 API,主要是一些工具方法和常量,比如Kernel.ts
中用到的四个方法:
// 常量:node_modules,用作第三方依赖路径变量 NODE_MODULES, // 查找node_modules路径(ctx.paths.nodeModulesPath的获取来源就是此方法) recursiveFindNodeModules, // 给require注册babel,在运行时对所有插件进行即时编译 createBabelRegister, // https://www.npmjs.com/package/debug debug库的使用别名,用来在控制台打印信息,支持高亮、命名空间等高级用法 createDebug
其中createBabelRegister
方法在开源项目里使用频率较高,其扩展用法: 通过createBabelRegister
,支持在app.config.ts
等commonJs
环境中使用import
或require
ctx.initialConfig
获取项目配置。
找到initialConfig: IProjectConfig
类型定义文件,可以看到结构跟Taro
项目的config
下的配置文件约定的配置结构一致。
详情????编译配置详情
// taro/types/compile.d.ts export interface IProjectBaseConfig { projectName?: string date?: string designWidth?: number watcher?: any[] deviceRatio?: TaroGeneral.TDeviceRatio sourceRoot?: string outputRoot?: string env?: IOption alias?: IOption defineConstants?: IOption copy?: ICopyOptions csso?: TogglableOptions terser?: TogglableOptions uglify?: TogglableOptions sass?: ISassOptions plugins?: PluginItem[] presets?: PluginItem[] baseLevel?: number framework?: string } export interface IProjectConfig extends IProjectBaseConfig { ui?: { extraWatchFiles?: any[] } mini?: IMiniAppConfig h5?: IH5Config rn?: IH5Config [key: string]: any }
回头看Kernel.ts
中的init
方法,第一个主要流程就是initConfig
初始化项目配置,也就是你项目根目录下的config
目录配置的那些配置项。
// taro-service/src/Kernel.ts initConfig () { this.config = new Config({ appPath: this.appPath }) this.initialConfig = this.config.initialConfig this.debugger('initConfig', this.initialConfig) }
Config
类会去找到项目的config/index.js
文件去初始化配置信息
// taro-service/src/Config.ts constructor (opts: IConfigOptions) { this.appPath = opts.appPath this.init() } init () { this.configPath = resolveScriptPath(path.join(this.appPath, CONFIG_DIR_NAME, DEFAULT_CONFIG_FILE)) if (!fs.existsSync(this.configPath)) { this.initialConfig = {} this.isInitSuccess = false } else { createBabelRegister({ only: [ filePath => filePath.indexOf(path.join(this.appPath, CONFIG_DIR_NAME)) >= 0 ] }) try { this.initialConfig = getModuleDefaultExport(require(this.configPath))(merge) this.isInitSuccess = true } catch (err) { this.initialConfig = {} this.isInitSuccess = false console.log(err) } } }
ctx.paths
Kernel.ts
中的init
方法第二个主要流程就是初始化插件环境变量ctx.paths
,包含当前执行命令的相关路径,所有的路径如下(并不是所有命令都会拥有以下所有路径):
ctx.paths.appPath
,当前命令执行的目录,如果是build
命令则为当前项目路径ctx.paths.configPath
,当前项目配置目录,如果init
命令,则没有此路径ctx.paths.sourcePath
,当前项目源码路径ctx.paths.outputPath
,当前项目输出代码路径ctx.paths.nodeModulesPath
,当前项目所用的 node_modules 路径
源码如下:
// taro-service/src/Kernel.ts initPaths () { this.paths = { appPath: this.appPath, nodeModulesPath: recursiveFindNodeModules(path.join(this.appPath, NODE_MODULES)) } as IPaths if (this.config.isInitSuccess) { Object.assign(this.paths, { configPath: this.config.configPath, sourcePath: path.join(this.appPath, this.initialConfig.sourceRoot as string), outputPath: path.join(this.appPath, this.initialConfig.outputRoot as string) }) } this.debugger(`initPaths:${JSON.stringify(this.paths, null, 2)}`) }
ctx.plugins
Kernel.ts
的init
方法第三个主要流程就是initPresetsAndPlugins
初始化预设和插件,也是init
中最复杂的一个流程,主要产物有ctx.plugins
和ctx.extraPlugins
。
在官方文档里介绍的插件功能有关预设这块只是草草几句带过了,而且并没有给出demo
解释如何使用,但是留下了一个比较重要的概念--预设是一系列插件的集合。
文档里给出的预设例子如下:
const config = { presets: [ // 引入 npm 安装的插件集 '@tarojs/preset-sth', // 引入 npm 安装的插件集,并传入插件参数 ['@tarojs/plugin-sth', { arg0: 'xxx' }], // 从本地绝对路径引入插件集,同样如果需要传入参数也是如上 '/absulute/path/preset/filename', ] }
只是给了presets的配置,但是并不清楚'@tarojs/preset-sth'
或者/absulute/path/preset/filename
插件内部是怎么实现的。于是查阅源码,因为Taro
内部有一系列内置的预设,在初始化Kernel
的时候就传给options
了,在前面CLI流程的第四步其实可以看到如下:
// taro-cli/src/cli.ts const kernel = new Kernel({ appPath: this.appPath, presets: [ path.resolve(__dirname, '.', 'presets', 'index.js') ] })
于是找到taro-cli/src/presets/index.ts
(省略部分代码):
import * as path from 'path' export default () => { return { plugins: [ // platforms path.resolve(__dirname, 'platforms', 'h5.js'), path.resolve(__dirname, 'platforms', 'rn.js'), path.resolve(__dirname, 'platforms', 'plugin.js'), ['@tarojs/plugin-platform-weapp', { backup: require.resolve('@tarojs/plugin-platform-weapp') }], ['@tarojs/plugin-platform-alipay', { backup: require.resolve('@tarojs/plugin-platform-alipay') }], ['@tarojs/plugin-platform-swan', { backup: require.resolve('@tarojs/plugin-platform-swan') }], ['@tarojs/plugin-platform-tt', { backup: require.resolve('@tarojs/plugin-platform-tt') }], ['@tarojs/plugin-platform-qq', { backup: require.resolve('@tarojs/plugin-platform-qq') }], ['@tarojs/plugin-platform-jd', { backup: require.resolve('@tarojs/plugin-platform-jd') }], // commands path.resolve(__dirname, 'commands', 'build.js'), // ... 省略其他 // files path.resolve(__dirname, 'files', 'writeFileToDist.js'), // ... 省略其他 // frameworks ['@tarojs/plugin-framework-react', { backup: require.resolve('@tarojs/plugin-framework-react') }], // ... 省略其他 ] } }
那模仿他写一个不就行了?
// projectRoot/src/prests/custom-presets.js const path = require('path'); module.exports = () => { return { plugins: [ path.resolve(__dirname, '..', 'plugin/compiler-optimization.js'), path.resolve(__dirname, '..', 'plugin/global-less-variable-ext.js'), ], }; };
总结:
-
预设
是一些列插件的集合,一个预设文件应该返回包含
plugins
配置的插件数组。 -
插件
具有固定的代码结构,返回一个功能函数,其中第一个参数是打包过程中的上下信息ctx,ctx中可以拿到一个重要的参数
modifyWebpackChain
,通过它修改webpack配置,第二个参数是options
,可以在config
下的plugins
中定义插件的地方传入该插件所需要的参数。插件部分可以参考文档,描述的算是比较清楚了。
初始化预设跟插件的流程如下:
initPresetsAndPlugins () { const initialConfig = this.initialConfig // 框架内置的插在件taro-cli/src/presets下 // 收集预设集合,一个 preset 是一系列 Taro 插件的集合。 // 将预设的插件跟项目config下自定义插件收集一块 const allConfigPresets = mergePlugins(this.optsPresets || [], initialConfig.presets || [])() // 收集插件并转化为集合对象,包括框架内置插件和自己自定义的插件 const allConfigPlugins = mergePlugins(this.optsPlugins || [], initialConfig.plugins || [])() this.debugger('initPresetsAndPlugins', allConfigPresets, allConfigPlugins) // 给require注册babel,在运行时对所有插件进行即时编译 // 扩展用法: 通过createBabelRegister,支持在app.config.ts中使用import或require process.env.NODE_ENV !== 'test' && createBabelRegister({ only: [...Object.keys(allConfigPresets), ...Object.keys(allConfigPlugins)] }) this.plugins = new Map() this.extraPlugins = {} // 加载了所有的 presets 和 plugin,最后都以 plugin 的形式注册到 kernel.plugins 集合中(this.plugins.set(plugin.id, plugin)) // 包含了插件方法的初始化 this.resolvePresets(allConfigPresets) this.resolvePlugins(allConfigPlugins) }
插件方法
诸如ctx.register
、ctx.registerMethod
、ctx.registerCommand
、ctx.registerPlatform
、ctx.applyPlugins
、ctx.addPluginOptsSchema
、ctx.generateProjectConfig
这些文档中介绍的插件方法,可以看到都是从插件的ctx
中取的,那插件的这些方法是在构建中的什么阶段被注册进去,以及它的流转是怎样的呢?
插件方法的定义都在taro-service/src/Plugin.ts
的Plugin
类中,我们的自定义插件(包括预设)和Taro
内置的插件(包括预设)都会在上述初始化预设跟插件方法initPresetsAndPlugins
中的resolvePresets
和resolvePlugins
的流程中被初始化,逐个对每个插件进行初始化工作:
// resolvePresets while (allPresets.length) { const allPresets = resolvePresetsOrPlugins(this.appPath, presets, PluginType.Preset) this.initPreset(allPresets.shift()!) } // resolvePlugins while (allPlugins.length) { plugins = merge(this.extraPlugins, plugins) const allPlugins = resolvePresetsOrPlugins(this.appPath, plugins, PluginType.Plugin) this.initPlugin(allPlugins.shift()!) this.extraPlugins = {} }
每个插件在初始化之前都被resolvePresetsOrPlugins
方法包装过,找到taro-service/src/utils/index.ts
中该方法的定义:
// getModuleDefaultExport export function resolvePresetsOrPlugins (root: string, args, type: PluginType): IPlugin[] { return Object.keys(args).map(item => { let fPath try { fPath = resolve.sync(item, { basedir: root, extensions: ['.js', '.ts'] }) } catch (err) { if (args[item]?.backup) { // 如果项目中没有,可以使用 CLI 中的插件 // taro预设的插件部分设置了backup,也就是备份的,他会通过require.resolve查找到模块路径。如果项目中没有此插件,就会去拿taro框架CLI里内置的插件 fPath = args[item].backup } else { console.log(chalk.red(`找不到依赖 "${item}",请先在项目中安装`)) process.exit(1) } } return { id: fPath, // 插件绝对路径 path: fPath, // 插件绝对路径 type, // 是预设还是插件 opts: args[item] || {}, // 一些参数 apply () { // 返回插件文件里面本身的内容,getModuleDefaultExport做了一层判断,是不是esModule模块exports.__esModule ? exports.default : exports return getModuleDefaultExport(require(fPath)) } } }) }
在initPreset
和initPlugin
中,一个比较重要的流程--initPluginCtx
,它做了初始化插件的上下文的工作内容,其中调用initPluginCtx
方法时,把Kernel
当成参数传给了ctx
属性,此外还有id
和path
,我们已经知道,这两个值都是插件的绝对路径。
// taro-service/src/Kernel.ts initPreset const pluginCtx = this.initPluginCtx({ id, path, ctx: this })
正是在initPluginCtx
中,第一次看到了跟本文主题最紧密的一个词—Plugin
,打开Plugin
类定义文件,其中找到了所有在文档中给开发者扩展的那些插件方法,也就是上述中插件方法开头介绍的那几个方法。
// taro-service/src/Plugin.ts export default class Plugin { id: string path: string ctx: Kernel optsSchema: (...args: any[]) => void constructor (opts) { this.id = opts.id this.path = opts.path this.ctx = opts.ctx } register (hook: IHook) {// ...} registerCommand (command: ICommand) {// ...} registerPlatform (platform: IPlatform) {// ...} registerMethod (...args) {// ...} function processArgs (args) {// ...} addPluginOptsSchema (schema) { this.optsSchema = schema } }
等等,不是说所有吗?那writeFileToDist
、generateFrameworkInfo
、generateProjectConfig
怎么没看到?其实在初始化预设的时候,这三个词就已经出现过了,之前在介绍ctx.plugins
的时候提到了taro-cli/src/presets/index.ts
内置预设文件,其中files
部分代码被省略了,这里重新贴一下:
// taro-cli/src/presets/index.ts // files path.resolve(__dirname, 'files', 'writeFileToDist.js'), path.resolve(__dirname, 'files', 'generateProjectConfig.js'), path.resolve(__dirname, 'files', 'generateFrameworkInfo.js')
以writeFileToDist
举例,详细看看这个插件实现了什么功能:
// taro-cli/src/presets/files/writeFileToDist.ts export default (ctx: IPluginContext) => { ctx.registerMethod('writeFileToDist', ({ filePath, content }) => { const { outputPath } = ctx.paths const { printLog, processTypeEnum, fs } = ctx.helper if (path.isAbsolute(filePath)) { printLog(processTypeEnum.ERROR, 'ctx.writeFileToDist 不能接受绝对路径') return } const absFilePath = path.join(outputPath, filePath) fs.ensureDirSync(path.dirname(absFilePath)) fs.writeFileSync(absFilePath, content) }) }
可以看到writeFileToDist
这个方法是通过registerMethod
注册到ctx
了,其他两个方法同理。
registerMethod
ctx.registerMethod(arg: string | { name: string, fn?: Function }, fn?: Function)
Taro
官方文档也给了我们解释—向 ctx
上挂载一个方法可供其他插件直接调用。
回到Plugin
本身,细究其每个属性方法,先找到registerMethod
:
// 向 ctx 上挂载一个方法可供其他插件直接调用。 registerMethod (...args) { const { name, fn } = processArgs(args) // ctx(也就是Kernel实例)上去找有没有这个方法,有的话就拿已有方法的回调数组,否则初始化一个空数组 const methods = this.ctx.methods.get(name) || [] // fn为undefined,说明注册的该方法未指定回调函数,那么相当于注册了一个 methodName 钩子 methods.push(fn || function (fn: (...args: any[]) => void) { this.register({ name, fn }) }.bind(this)) this.ctx.methods.set(name, methods) }
register
ctx.register(hook: IHook)
interface IHook { // Hook 名字,也会作为 Hook 标识 name: string // Hook 所处的 plugin id,不需要指定,Hook 挂载的时候会自动识别 plugin: string // Hook 回调 fn: Function before?: string stage?: number }
注册一个可供其他插件调用的钩子,接收一个参数,即 Hook 对象。通过 ctx.register
注册过的钩子需要通过方法 ctx.applyPlugins
进行触发。
Plugin
中register
的方法定义如下:
// 注册钩子一样需要通过方法 ctx.applyPlugins 进行触发 register (hook: IHook) { if (typeof hook.name !== 'string') { throw new Error(`插件 ${this.id} 中注册 hook 失败, hook.name 必须是 string 类型`) } if (typeof hook.fn !== 'function') { throw new Error(`插件 ${this.id} 中注册 hook 失败, hook.fn 必须是 function 类型`) } const hooks = this.ctx.hooks.get(hook.name) || [] hook.plugin = this.id this.ctx.hooks.set(hook.name, hooks.concat(hook)) }
通过register
注册的钩子会自动注入当前插件的id
(绝对路径),最后合并到ctx.hooks
中,待applyPlugins
调用
registerCommand
ctx.registerCommand(hook: ICommand)
一个感觉很有想象空间的方法,可以自定义指令,例如taro create xxx
,可以按照需求快速生成一些通用模板、组件或者方法等等。
ICommand
继承于IHook
export interface ICommand extends IHook { alias?: string, optionsMap?: { [key: string]: string }, synopsisList?: string[] }
因此register
也可以直接注册自定义指令,ctx
缓存此指令到commands
registerCommand (command: ICommand) { if (this.ctx.commands.has(command.name)) { throw new Error(`命令 ${command.name} 已存在`) } this.ctx.commands.set(command.name, command) this.register(command) }
registerPlatform
ctx.registerPlatform(hook: IPlatform)
注册一个编译平台。IPlatform
同样继承于IHook
,最后同样被注册到hooks
,具体使用方法详见文档。】
registerPlatform (platform: IPlatform) { if (this.ctx.platforms.has(platform.name)) { throw new Error(`适配平台 ${platform.name} 已存在`) } addPlatforms(platform.name) this.ctx.platforms.set(platform.name, platform) this.register(platform) }
applyPlugins
ctx.applyPlugins(args: string | { name: string, initialVal?: any, opts?: any })
触发注册的钩子。修改类型和添加类型的钩子拥有返回结果,否则不用关心其返回结果。
使用方式:
ctx.applyPlugins('onStart') const assets = await ctx.applyPlugins({ name: 'modifyBuildAssets', initialVal: assets, opts: { assets } })
addPluginOptsSchema
ctx.addPluginOptsSchema(schema: Function)
为插件入参添加校验,接受一个函数类型参数,函数入参为 joi 对象,返回值为 joi schema。
在初始化插件initPlugin
中最终会调用Kernel
的checkPluginOpts
校验插件入参类型是否正常:
checkPluginOpts (pluginCtx, opts) { if (typeof pluginCtx.optsSchema !== 'function') { return } const schema = pluginCtx.optsSchema(joi) if (!joi.isSchema(schema)) { throw new Error(`插件${pluginCtx.id}中设置参数检查 schema 有误,请检查!`) } const { error } = schema.validate(opts) if (error) { error.message = `插件${pluginCtx.id}获得的参数不符合要求,请检查!` throw error } }
到这里为止,插件方法的作用及其在源码中的实现方式已经大致了解了,其实插件方法开头说的initPluginCtx
中的流程才走完第一步。
插件上下文信息获取逻辑
initPluginCtx ({ id, path, ctx }: { id: string, path: string, ctx: Kernel }) { const pluginCtx = new Plugin({ id, path, ctx }) // 定义插件的两个内部方法(钩子函数): onReady和onStart const internalMethods = ['onReady', 'onStart'] // 定义一些api const kernelApis = [ 'appPath', 'plugins', 'platforms', 'paths', 'helper', 'runOpts', 'initialConfig', 'applyPlugins' ] // 注册onReady和onStart钩子,缓存到ctx.methods中 internalMethods.forEach(name => { if (!this.methods.has(name)) { pluginCtx.registerMethod(name) } }) return new Proxy(pluginCtx, { // 参数:目标对象,属性名 get: (target, name: string) => { if (this.methods.has(name)) { // 优先从Kernel的methods中找此属性 const method = this.methods.get(name) // 如果是方法数组则返回遍历数组中函数并执行的方法 if (Array.isArray(method)) { return (...arg) => { method.forEach(item => { item.apply(this, arg) }) } } return method } // 如果访问的是以上kernelApis中的一个,判断是方法则返回方法,改变了this指向,是普通对象则返回此对象 if (kernelApis.includes(name)) { return typeof this[name] === 'function' ? this[name].bind(this) : this[name] } // Kernel中没有就返回pluginCtx的此属性 return target[name] } }) }
initPluginCtx
最终返回了Proxy
代理对象,后续执行插件方法的时候会把该上下文信息(也就是这个代理对象)当成第一个参数传给插件的apply
方法调用,apply
的第二个参数就是插件参数了。
因此,当我们在插件开发的时候,从ctx
中去获取相关属性值,就需要走Proxy
中的逻辑。可以从源码中看到,属性优先是从Kernel
实例去拿的,Kernel
实例中的methods
没有此方法,则从Plugin
对象上去取。
此时插件的上下文中已经有两个内部的钩子,onReady
和onStart
。
注意:pluginCtx.registerMethod(name)
,注册internalMethods
的时候,并没有传回调方法,因此开发者在自己编写插件时可以注册对应的钩子,在钩子里执行自己的逻辑代码
内置插件钩子函数执行时机
初始化预设和插件后,至此,开始执行第一个钩子函数—onReady
。此时流程已经走到上述插件的主要流程中的最后一步:
// Kernel.init await this.applyPlugins('onReady')
回头看CLI流程的第六步,回顾Kernel.ts
的run
方法中的执行流程,在执行onReady
的钩子后就执行了onStart
钩子,同样,注册此钩子也没有执行操作,如需要开发者可以去添加回调函数在onStart
时执行操作。
run
继续往下执行了modifyRunnerOpts
钩子,其作用就是:修改webpack
参数,例如修改 H5 postcss options
。
执行平台命令
Kernel.run
最后一个流程就是执行命令。
// 执行传入的命令 await this.applyPlugins({ name, opts })
这里可以解释清楚最终yarn start
后Taro
到底做了哪些事,执行了yarn start
后最终的脚本是taro build --type xxx
,在前面预设和插件初始化的时候提到过,taro
有许多内置的插件(预设)会初始化掉,这些钩子函数会缓存在Kernel
实例中,taro
内置预设存放在taro-cli/src/presets/
下,这次具体看一下到底有哪些内置的插件,先看大体的目录:
在commands下可以看到许多我们眼熟的指令名称,如create
、doctor
、help
、build
等等,constants
下定义一些内置的钩子函数名称,例如:modifyWebpackChain
、onBuildStart
、modifyBuildAssets
、onCompilerMake
等等,files
下三个插件之前在插件方法中已经解释了,platforms
下主要是注册平台相关的指令,以h5
平台举例:
// taro-cli/src/presets/platforms/h5.ts export default (ctx: IPluginContext) => { ctx.registerPlatform({ name: 'h5', useConfigName: 'h5', async fn ({ config }) { const { appPath, outputPath, sourcePath } = ctx.paths const { initialConfig } = ctx const { port } = ctx.runOpts const { emptyDirectory, recursiveMerge, npm, ENTRY, SOURCE_DIR, OUTPUT_DIR } = ctx.helper emptyDirectory(outputPath) const entryFileName = `${ENTRY}.config` const entryFile = path.basename(entryFileName) const defaultEntry = { [ENTRY]: [path.join(sourcePath, entryFile)] } const customEntry = get(initialConfig, 'h5.entry') const h5RunnerOpts = recursiveMerge(Object.assign({}, config), { entryFileName: ENTRY, env: { TARO_ENV: JSON.stringify('h5'), FRAMEWORK: JSON.stringify(config.framework), TARO_VERSION: JSON.stringify(getPkgVersion()) }, port, sourceRoot: config.sourceRoot || SOURCE_DIR, outputRoot: config.outputRoot || OUTPUT_DIR }) h5RunnerOpts.entry = merge(defaultEntry, customEntry) const webpackRunner = await npm.getNpmPkg('@tarojs/webpack-runner', appPath) webpackRunner(appPath, h5RunnerOpts) } }) }
平时我们在配置h5的时候,会给h5单独设置入口,只要把入口文件名称改成index.h5.js
,配置文件也是如此:index.h5.config
,想必现在应该知道为什么可以这么做了吧。
回到`taro build --type xxx
,由build
指令找到其定义文件所在位置—taro-cli/src/presets/commands/build.ts
,插件方法中介绍完registerCommand
可知:指令(commands
)缓存到上下文commands
后最终也是调用了regigter
注册了该指令钩子函数,这也是为什么执行命令时调用applyPlugins
可以执行build
指令的原由。如下可知build
指令大致做了哪些工作:
import { IPluginContext } from '@tarojs/service' import * as hooks from '../constant' import configValidator from '../../doctor/configValidator' export default (ctx: IPluginContext) => { // 注册编译过程中的一些钩子函数 registerBuildHooks(ctx) ctx.registerCommand({ name: 'build', optionsMap: {}, synopsisList: [], async fn (opts) { // ... // 校验 Taro 项目配置 const checkResult = await checkConfig({ configPath, projectConfig: ctx.initialConfig }) // ... // 创建dist目录 fs.ensureDirSync(outputPath) // ... // 触发onBuildStart钩子 await ctx.applyPlugins(hooks.ON_BUILD_START) // 执行对应平台的插件方法进行编译 await ctx.applyPlugins({/** xxx */}) // 触发onBuildComplete钩子,编译结束! await ctx.applyPlugins(hooks.ON_BUILD_COMPLETE) } }) } function registerBuildHooks (ctx) { [ hooks.MODIFY_WEBPACK_CHAIN, hooks.MODIFY_BUILD_ASSETS, hooks.MODIFY_MINI_CONFIGS, hooks.MODIFY_COMPONENT_CONFIG, hooks.ON_COMPILER_MAKE, hooks.ON_PARSE_CREATE_ELEMENT, hooks.ON_BUILD_START, hooks.ON_BUILD_FINISH, hooks.ON_BUILD_COMPLETE, hooks.MODIFY_RUNNER_OPTS ].forEach(methodName => { ctx.registerMethod(methodName) }) }
其中,关于对各个平台代码编译的工作都在ctx.applyPlugins({name: platform,opts: xxx})
中,以编译到小程序平台举例:
ctx.applyPlugins({ name: 'weapp', opts: { // xxx } )
既然要执行钩子weapp
,那么就需要有提前注册过这个钩子,weapp
这个hooks
是在哪个阶段被注册进去的呢?
讲解ctx.plugin
的时候有介绍初始化预设跟插件的流程—initPresetsAndPlugins
,此流程中会初始化框架内置的预设(插件),并且有提过框架内置预设是在taro-cli/src/presets/index.ts
,index.ts
中有关于平台(platform
)相关的插件:
export default () => { return { plugins: [ // platforms path.resolve(__dirname, 'platforms', 'h5.js'), path.resolve(__dirname, 'platforms', 'rn.js'), path.resolve(__dirname, 'platforms', 'plugin.js'), ['@tarojs/plugin-platform-weapp', { backup: require.resolve('@tarojs/plugin-platform-weapp') }], ['@tarojs/plugin-platform-alipay', { backup: require.resolve('@tarojs/plugin-platform-alipay') }], ['@tarojs/plugin-platform-swan', { backup: require.resolve('@tarojs/plugin-platform-swan') }], ['@tarojs/plugin-platform-tt', { backup: require.resolve('@tarojs/plugin-platform-tt') }], ['@tarojs/plugin-platform-qq', { backup: require.resolve('@tarojs/plugin-platform-qq') }], ['@tarojs/plugin-platform-jd', { backup: require.resolve('@tarojs/plugin-platform-jd') }], // commands // ... // files // ... // frameworks // ... ] } }
从中很容易就找到了所有可编译平台的插件源码所在目录,找到@tarojs/plugin-platform-weapp
所在目录,打开入口文件:
export default (ctx: IPluginContext, options: IOptions) => { ctx.registerPlatform({ name: 'weapp', useConfigName: 'mini', async fn ({ config }) { const program = new Weapp(ctx, config, options || {}) await program.start() } }) }
由此可知,小程序平台编译插件会首先registerPlatform:weapp
,而registerPlatform
操作最终会把weapp
注册到hooks
中。随后调用了program.start
方法,此方法定义在基类中,class Weapp extends TaroPlatformBase
,TaroPlatformBase类定义在taro-service/src/platform-plugin-base.ts
中,start
方法正是调用 mini-runner
开启编译,mini-runner
就是 webpack
编译程序,单独开一篇文章介绍,具体平台(platform
)编译插件的执行流程和其中具体细节也在后续单独的文章中介绍。
总结
本文按照Taro
的cli
执行流程顺序讲解了每个流程中Taro
做了哪些工作,并针对Taro
文章中插件开发的章目讲解了每个api
的由来和具体用法,深入了解Taro
在编译项目过程的各环节的执行原理,为项目中开发构建优化、拓展更多功能,为自身业务定制个性化功能夯实基础。
原文地址:https://segmentfault.com/a/1190000041515931