前言
自 2.2 開始,Taro 引入了外掛化機制,目的是為了讓開發者能夠通過編寫外掛的方式來為 Taro 擴充更多功能或為自身業務定製個性化功能。
本文基於Taro3.4.2原始碼講解
CLI流程
執行
cli
命令,如npm run start
,實際上在package.json
中script
指令碼列表中可以往下解讀一直找到build:weapp
這條指令碼所執行的對應具體指令資訊,dev
模式下區別prod
模式只是多了一個--watch
熱載入而已,只是區分了對應的env
環境,在webpack打包的時候分別預設了對應環境不同的打包配置,例如判斷生產環境才會預設啟用程式碼壓縮等- 那麼這個taro指令是在哪定義的呢?taro在你全域性安裝的時候就已經配置到環境變數了,我們專案目錄下去執行
`package.json
中的script
指令碼命令,它會在當前目錄下去找node
指令碼,找不到就向上級找,最終執行該指令碼。 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()
啟動後,
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 })
customCommand
中將所有的引數整理後呼叫Kernel.run
,傳入整理後的所有引數。kernel.run({ name: command, opts: { _: args._, options, isHelp: args.h } })
接下去就是在
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
在編譯專案過程的各環節的執行原理,為專案中開發構建優化、擴充更多功能,為自身業務定製個性化功能夯實基礎。