Taro cli流程和外掛化機制實現原理

MangoGoing發表於2022-03-09

前言

自 2.2 開始,Taro 引入了外掛化機制,目的是為了讓開發者能夠通過編寫外掛的方式來為 Taro 擴充更多功能或為自身業務定製個性化功能。

本文基於Taro3.4.2原始碼講解

CLI流程

  1. 執行cli命令,如npm run start,實際上在package.jsonscript指令碼列表中可以往下解讀一直找到build:weapp這條指令碼所執行的對應具體指令資訊,dev模式下區別prod模式只是多了一個--watch熱載入而已,只是區分了對應的env環境,在webpack打包的時候分別預設了對應環境不同的打包配置,例如判斷生產環境才會預設啟用程式碼壓縮等

    image-20220228080218285

  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例項先例項化了一個繼承EventEmitterKernel核心類(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
}

runOptstaro-service/src/Kernel.tsrun方法初始化,早於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.tscommonJs環境中使用importrequire

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.tsinit方法第三個主要流程就是initPresetsAndPlugins初始化預設和外掛,也是init中最複雜的一個流程,主要產物有ctx.pluginsctx.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.registerctx.registerMethodctx.registerCommandctx.registerPlatformctx.applyPluginsctx.addPluginOptsSchemactx.generateProjectConfig這些文件中介紹的外掛方法,可以看到都是從外掛的ctx中取的,那外掛的這些方法是在構建中的什麼階段被註冊進去,以及它的流轉是怎樣的呢?

外掛方法的定義都在taro-service/src/Plugin.tsPlugin類中,我們的自定義外掛(包括預設)和Taro內建的外掛(包括預設)都會在上述初始化預設跟外掛方法initPresetsAndPlugins中的resolvePresetsresolvePlugins的流程中被初始化,逐個對每個外掛進行初始化工作:

// 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))
      }
    }
  })
}

initPresetinitPlugin中,一個比較重要的流程--initPluginCtx,它做了初始化外掛的上下文的工作內容,其中呼叫initPluginCtx方法時,把Kernel當成引數傳給了ctx屬性,此外還有idpath,我們已經知道,這兩個值都是外掛的絕對路徑。

// 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
  }
}

等等,不是說所有嗎?那writeFileToDistgenerateFrameworkInfogenerateProjectConfig怎麼沒看到?其實在初始化預設的時候,這三個詞就已經出現過了,之前在介紹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 進行觸發。

Pluginregister的方法定義如下:

// 註冊鉤子一樣需要通過方法 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中最終會呼叫KernelcheckPluginOpts校驗外掛入參型別是否正常:

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物件上去取。

此時外掛的上下文中已經有兩個內部的鉤子,onReadyonStart

注意:pluginCtx.registerMethod(name),註冊internalMethods的時候,並沒有傳回撥方法,因此開發者在自己編寫外掛時可以註冊對應的鉤子,在鉤子裡執行自己的邏輯程式碼

內建外掛鉤子函式執行時機

初始化預設和外掛後,至此,開始執行第一個鉤子函式—onReady。此時流程已經走到上述外掛的主要流程中的最後一步:

// Kernel.init

await this.applyPlugins('onReady')

回頭看CLI流程的第六步,回顧Kernel.tsrun方法中的執行流程,在執行onReady的鉤子後就執行了onStart鉤子,同樣,註冊此鉤子也沒有執行操作,如需要開發者可以去新增回撥函式在onStart時執行操作。

run繼續往下執行了modifyRunnerOpts鉤子,其作用就是:修改webpack引數,例如修改 H5 postcss options

執行平臺命令

Kernel.run最後一個流程就是執行命令。

// 執行傳入的命令
await this.applyPlugins({
  name,
  opts
})

這裡可以解釋清楚最終yarn startTaro到底做了哪些事,執行了yarn start後最終的指令碼是taro build --type xxx,在前面預設和外掛初始化的時候提到過,taro有許多內建的外掛(預設)會初始化掉,這些鉤子函式會快取在Kernel例項中,taro內建預設存放在taro-cli/src/presets/下,這次具體看一下到底有哪些內建的外掛,先看大體的目錄:

image-20220305233407390

在commands下可以看到許多我們眼熟的指令名稱,如createdoctorhelpbuild等等,constants下定義一些內建的鉤子函式名稱,例如:modifyWebpackChainonBuildStartmodifyBuildAssetsonCompilerMake等等,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.tsindex.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)編譯外掛的執行流程和其中具體細節也在後續單獨的文章中介紹。

總結

本文按照Tarocli執行流程順序講解了每個流程中Taro做了哪些工作,並針對Taro文章中外掛開發的章目講解了每個api的由來和具體用法,深入瞭解Taro在編譯專案過程的各環節的執行原理,為專案中開發構建優化、擴充更多功能,為自身業務定製個性化功能夯實基礎。

相關文章