源码共读11:Taro源码揭秘 之 揭开整个框架的入口-taro init 初始化项目的秘密

239 阅读7分钟

Taro Github

本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。

这是学习源码整体架构系列,链接: juejin.cn/post/737836…


2024.07.15 更新:taro-cli Config类
2024.07.21 更新:taro-kernel(内核)


  1. 揭开整个框架的入口-taro init 初始化项目的秘密
  2. 揭开整个框架的插件系统的秘密

前言:

  1. 学会两种方法调试taro源码
  2. 学会入口 taro-cli 具体实现方式
  3. 学会cli init 命令实现原理,读取用户项目配置文件和用户全局配置文件
  4. 学会 taro-service kernel(内核)解藕实现
  5. 初步学会 taro 插件框架,学会如何编写一个 taro插件

1.准备工作:

# 克隆项目
git clone https://github.com/NervJS/taro.git
# 切换换到分支4.x
git checkout 4.x
# 4.0.0-beta.83 cf9dd497d284679810c175e659388842515c53c0
git checkout cf9dd497d284679810c175e659388842515c53c0

看一个开源项目,第一步应该是先看README.md再看贡献文档和package.json

环境准备

需要安装Node.js 16(建议安装16.20.0及以上版本)及pnpm7

一般使用nvm管理node版本

nvm install 18
nvm use 18
# 可以把 node 默认版本设置为 18,调试时会使用默认版本
nvm alias default 18

pnpm -v
# 9.5.0
node -v
# v18.20.2

cd taro
# 安装依赖
pnpm i
# 编译构建
pnpm build
# 删除根目录的 node_modules 和所有 workspace 里的 node_modules
pnpm run clear-all
# 对应的是:rimraf **/node_modules
# mac 下可以用 rm -rf **/node_modules

安装依赖可能会报错。

# 报错信息
Failed to set up Chromium r1108766! Set "PUPPETEER_SKIP_DOWNLOAD" env variable to skip download.

谷歌大法:stackoverflow

Mac : export PUPPETEER_SKIP_DOWNLOAD='true' Windows: SET PUPPETEER_SKIP_DOWNLOAD='true'

pnpm build 完成,如下图所示:

2.调试

package.json

2.1 入口文件 packages/taro-cli/bin/taro
2.2 调试方法 1 JavaScript Debug Terminal

简而言之:

  1. 找到入口文件设置断点
  2. ctrl + `(反引号) 打开终端,配置JavaScript调试终端
  3. 在终端输入 node 相关命令,这里以init 为例
  4. 调试代码
node ./packages/taro-cli/bin/taro init taro-init-debug

如图所示

调试时此时应该会报错,binding taro.[os-platform].node。如图

运行报错不要慌,这时看下贡献文档应该可以找到答案,来看下贡献文档-10-rust-部分

通过rustup找到安装命令:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

安装之后,执行pnpmrun build:building:debug 或pnpm run building:release产出编译文件:

crates/native_binding/taro.darwin-arm64.node

完美解决,调试时就不会报错了。

2.3 调试方式2 配置 .vscode/launch.json

taro 文档 - 单步调测配置 写的挺好的,通过配置 launch.json 来调试,在此就不再赘述了。

不过补充一条,launch.json 文件可以添加一条"console": "integratedTerminal" (集成终端)配置,就可以在调试终端输入内容了。args参数添加init和指定要初始化项目的文件夹。当然调试其他的时候也可以修改为其他参数。比如args: ["build", "--type", "weapp", "--watch"]。

{
  // Use IntelliSense to learn about possible attributes.
  // Hover to view descriptions of existing attributes.
  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "CLI debug",
      "program": "${workspaceFolder}/packages/taro-cli/bin/taro",
      // "cwd": "${project absolute path}",
      "args": [
        "init",
        "taro-init-debug",
        // "build",
        // "--type",
        // "weapp",
        // "--watch"
      ],
      "skipFiles": ["<node_internals>/**"]
    },
  ]
}

vscode nodejs 调试

console- 启动程序的控制台(internalConsole,integratedTerminal,externalTerminal)。

我们跟着断点进入,入口文件中的第一句require("../dist/util").printPkgVersion(); printPkgVersion函数,打印taro/packages/taro-cli/package.json版本号。

3. taro-cli/src/utils/index.ts

工具函数

import { chalk, fs, isWindows } from '@tarojs/helper'
import { exec } from 'child_process'
import * as path from 'path'

export function getRootPath (): string {
  return path.resolve(__dirname, '../../')
}

export function getPkgVersion (): string {
  return require(path.join(getRootPath(), 'package.json')).version
}

export function printPkgVersion () {
  const taroVersion = getPkgVersion()
  console.log(`👽 Taro v${taroVersion}`)
  console.log()
}

// code...
// 代码有删减

4. CLI 整体结构

这个文件整体结构。class CLI 包含一个appPath属性(一般指taro工作目录),两个函数(run和parseArgs)。

// packages/taro-cli/src/cli.ts
class CLI {
    constructor(appPath) {
        this.appPath = appPath || process.cwd();
    }
    run() {
        return this.parseArgs();
    }
    parseArgs() {
           const args = minimist(process.argv.slice(2), {
                alias: {
                  // 别名设置
                },
                boolean: ['version', 'help', 'disable-global-config'],
                default: {
                    build: true,
                },
            });
            const _ = args._;
            const command = _[0];
            if (command) {
              // 设置环境变量、注册命令
              // 并执行命令
            }
            else {
              if (args.h) {
                // 帮助信息
              } else if (args.v) {
                 // 输出版本号
                 console.log((0, util_1.getPkgVersion)());
              }
    }
    
}

使用了minimist,参数解析工具。

同类工具还有:

Commander.jscacyargs

cli.run 最终调用的是cli.parseArgs

5. cli parseArgs

  5.1 presets预设插件集合
// packages/taro-cli/src/cli.ts
parseArgs() {
           const args = minimist(process.argv.slice(2), {
                alias: {
                  // 别名设置
                },
                boolean: ['version', 'help', 'disable-global-config'],
                default: {
                    build: true,
                },
            });
            const _ = args._;
            const command = _[0];
            if (command) {
              const appPath = this.appPath;
                const presetsPath = path.resolve(__dirname, 'presets');
                const commandsPath = path.resolve(presetsPath, 'commands');
                const platformsPath = path.resolve(presetsPath, 'platforms');
                const commandPlugins = helper_1.fs.readdirSync(commandsPath);
                const targetPlugin = `${command}.js`;
                // 设置环境变量
                (_c = process.env).NODE_ENV || (_c.NODE_ENV = args.env);
                if (process.env.NODE_ENV === 'undefined' && (command === 'build' || command === 'inspect')) {
                    process.env.NODE_ENV = (args.watch ? 'development' : 'production');
                }
                args.type || (args.type = args.t);
                if (args.type) {
                    process.env.TARO_ENV = args.type;
                }
                if (typeof args.plugin === 'string') {
                    process.env.TARO_ENV = 'plugin';
                }
                // code...
            }
            else {
              if (args.h) {
                // 帮助信息
              } else if (args.v) {
                 // 输出版本号
                 console.log((0, util_1.getPkgVersion)());
              }
    }

presets对应目录结构如下:

/taro/packages/taro-cli/dist/presets/commands/*

5.2 Config
// packages/taro-cli/src/cli.ts
const config = new Config({
     appPath: this.appPath,
     disableGlobalConfig: disableGlobalConfig
});
```

```
// packages/taro-cli/src/cli.ts
// 这里解析 dotenv 以便于 config 解析时能获取 dotenv 配置信息
const expandEnv = (0, helper_1.dotenvParse)(appPath, args.envPrefix, mode);
const disableGlobalConfig = !!(args['disable-global-config'] || DISABLE_GLOBAL_CONFIG_COMMANDS.includes(command));
const configEnv = {
       mode,
       command,
};

dotenvParse函数简单来说就是通过dotenvdotenv-expand解析.env.env.development.env.production 等变量。

dotenv是一个零依赖的模块,将.env的文件中环境变量加载到process.env中。

  Config类

// packages/taro-service/src/Config.ts
export default class Config {
  appPath: string
  configPath: string
  initialConfig: IProjectConfig
  initialGlobalConfig: IProjectConfig
  isInitSuccess: boolean
  disableGlobalConfig: boolean

  constructor (opts: IConfigOptions) {
    this.appPath = opts.appPath
    this.disableGlobalConfig = !!opts?.disableGlobalConfig
  }
  async init (configEnv: {
    mode: string
    command: string
  }) {
  }
  initGlobalConfig () {
      code...
  }
  getConfigWithNamed (platform, configName) {
      code...
  }
}

Config构造函数有两个属性:appPath是taro项目的路径,disableGlobalConfig是禁用全局配置。

  5.2.1 config.init 读取配置

  读取的是config/index.(js|ts)路径。判断是否禁用全局配置。不禁用读取全局配置.taro-global-config/index.json。

async init (configEnv: {
    mode: string
    command: string
  }) {
    this.initialConfig = {}
    this.initialGlobalConfig = {}
    this.isInitSuccess = false
    this.configPath = resolveScriptPath(path.join(this.appPath, CONFIG_DIR_NAME, DEFAULT_CONFIG_FILE))
    if (!fs.existsSync(this.configPath)) {
      if (this.disableGlobalConfig) return
      this.initGlobalConfig()
    } else {
      createSwcRegister({
        only: [
          filePath => filePath.indexOf(path.join(this.appPath, CONFIG_DIR_NAME)) >= 0
        ]
      })
      try {
        const userExport = getModuleDefaultExport(require(this.configPath))
        this.initialConfig = typeof userExport === 'function' ? await userExport(merge, configEnv) : userExport
        this.isInitSuccess = true
      } catch (err) {
        console.log(err)
      }
    }
  }

createSwcRegister使用了@swc/register 来编译ts等转换为common.js。可以直接使用require。

使用 swc 的方法之一是通过 require 钩子。require 钩子会将自身绑定到 noderequire 并自动动态编译文件。不过现在更推荐 @swc-node/register

// 这句就是config/index.ts支持函数也支持对象的实现。
const getModuleDefaultExport = exports => exports.__esModule ? exports.default : exports;

5.2.2 config.initGlobalConfig 初始化全局配置

  读取配置~/.taro-global-config/index.json

{
    "plugins": [],
    "presets": []
}
initGlobalConfig () {
    const homedir = getUserHomeDir()
    if (!homedir) return console.error('获取不到用户 home 路径')
    const globalPluginConfigPath = path.join(getUserHomeDir(), TARO_GLOBAL_CONFIG_DIR, TARO_GLOBAL_CONFIG_FILE)
    if (!fs.existsSync(globalPluginConfigPath)) return
    const spinner = ora(`开始获取 taro 全局配置文件: ${globalPluginConfigPath}`).start()
    try {
      this.initialGlobalConfig = fs.readJSONSync(globalPluginConfigPath) || {}
      spinner.succeed('获取 taro 全局配置成功')
    } catch (e) {
      spinner.stop()
      console.warn(`获取全局配置失败,如果需要启用全局插件请查看配置文件: ${globalPluginConfigPath} `)
    }
  }

getUserHomeDir函数主要是获取用户的主页路径。比如,mac中是/User/用户名。如果支持os.homedir()直接获取返回,如果不支持则根据操作系统和环境变量判断获取。

ora 是控制台的 loading 小动画。

这里的fs是@tarojs/helper导出的fs-extra

fs-extra 添加本机模块中未包含的文件系统方法 fs,并为这些方法添加承诺支持 fs。它还用于 graceful-fs 防止 EMFILE 错误。它应该是 的替代品 fs。

使用 fs.readJSONSync 同步读取 json 的方法。

官网文档描述:全局插件或插件集配置

官网文档描述:全局插件或插件集配置

6.Kernel(内核)

// packages/taro-cli/src/cli.ts
   const kernel = new Kernel({
        appPath,
        presets: [
          path.resolve(__dirname, '.', 'presets', 'index.js')
        ],
        config,
        plugins: []
      })
      kernel.optsPlugins ||= []

Kernel类继承自Nodejs的事件模块EventEmitter。

// packages/taro-service/src/Kernel.ts
export default class Kernel extends EventEmitter {
  constructor (options: IKernelOptions) {
    super()
    this.debugger = process.env.DEBUG === 'Taro:Kernel' ? helper.createDebug('Taro:Kernel') : function () {}
    this.appPath = options.appPath || process.cwd()
    this.optsPresets = options.presets
    this.optsPlugins = options.plugins
    this.config = options.config
    this.hooks = new Map()
    this.methods = new Map()
    this.commands = new Map()
    this.platforms = new Map()
    this.initHelper()
    this.initConfig()
    this.initPaths()
    this.initRunnerUtils()
  }
}

this.debugger当没有配置DEBUG 环境变量时,则debugger是空函数,如果配置了process.env.DEBUG === 'Taro:Kernel'则为调用npm包 debug

构造器中有几个初始化函数,基本含义都是顾名思义。

// packages/taro-service/src/Kernel.ts
// 项目配置
initConfig () {
    // 省略部分代码
}
// Taro 编译时工具库,主要供 CLI、编译器插件使用
initHelper() {
    // 省略部分代码
}
// 当前执行命令的相关路径
initPaths () {
    this.paths = {
      appPath: this.appPath,
      nodeModulesPath: helper.recursiveFindNodeModules(path.join(this.appPath, helper.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.resolve(this.appPath, this.initialConfig.outputRoot as string)
      })
    }
    this.debugger(`initPaths:${JSON.stringify(this.paths, null, 2)}`)
}
// 暴露给 runner 的公用工具函数
initRunnerUtils() {
  this.runnerUtils = runnerUtils
  this.debugger('initRunnerUtils')
}

初始化后的参数如taro 官方文档 - 编写插件 api

6.1 cli Kernel.optsPlugins 等
   kernel.optsPlugins ||= []

  // code...
  if (command === 'doctor') {
     kernel.optsPlugins.push('@tarojs/plugin-doctor')
  } else if (commandPlugins.includes(targetPlugin)) {
    // 针对不同的内置命令注册对应的命令插件
    kernel.optsPlugins.push(path.resolve(commandsPath, targetPlugin))
  }
  // code...
  // 省略部分代码
6.2 cli customCommand 函数
 switch (command) {
    case 'inspect':
    case 'build': {
        // code...
        // 省略部分代码
        if (command === 'inspect') {
            customCommand(command, kernel, args)
            break
          }
          customCommand(command, kernel, {
          // args
          })
          break
    }
    case 'init': {
          customCommand(command, kernel, {
            // args
          }
          break
    default:
          customCommand(command, kernel, args)
          break

最终调用的是customCommand函数

// packages/taro-cli/src/commands/customCommand.ts
import { Kernel } from '@tarojs/service'

export default function customCommand (
  command: string,
  kernel: Kernel,
  args: { _: string[], [key: string]: any }
) {
  if (typeof command === 'string') {
    const options: any = {}
    const excludeKeys = ['_', 'version', 'v', 'help', 'h', 'disable-global-config']
    Object.keys(args).forEach(key => {
      if (!excludeKeys.includes(key)) {
        options[key] = args[key]
      }
    })

    kernel.run({
      name: command,
      opts: {
        _: args._,
        options,
        isHelp: args.h
      }
    })
  }
}

可以看出customCommand 函数移除一些 run函数不需要的参数,最终调用的是kernel.run函数。

7.kernel.run 执行函数

将run函数分成两部分解析:

// packages/taro-service/src/Kernel.ts
async run (args: string | { name: string, opts?: any }) {
    let name
    let opts
    if (typeof args === 'string') {
      name = args
    } else {
      name = args.name
      opts = args.opts
    }
    this.debugger('command:run')
    this.debugger(`command:run:name:${name}`)
    this.debugger('command:runOpts')
    this.debugger(`command:runOpts:${JSON.stringify(opts, null, 2)}`)
    // 将参数暂存起来
    this.setRunOpts(opts)

    // code...
    // 省略下半部分
  }
// packages/taro-service/src/Kernel.ts
  setRunOpts (opts) {
    // 将参数暂存起来
    // Taro 文档 - 编写插件 - ctx.runOpts
    this.runOpts = opts
  }

run函数下半部分:

主要有三个函数:

  1. this.initPresetsAndPlugins() 函数,顾名知义。初始化预设插件集合和插件。
  2. this.applyPlugins() 执行插件
  3. this.runHelp() 执行 命令行的帮助信息,例:taro init --help
async run (args: string | { name: string, opts?: any }) {
    // code...
    // 省略上半部分

    this.debugger('initPresetsAndPlugins')
    this.initPresetsAndPlugins()

    await this.applyPlugins('onReady')

    this.debugger('command:onStart')
    await this.applyPlugins('onStart')

    if (!this.commands.has(name)) {
      throw new Error(`${name} 命令不存在`)
    }

    if (opts?.isHelp) {
      return this.runHelp(name)
    }

    if (opts?.options?.platform) {
      opts.config = this.runWithPlatform(opts.options.platform)
      await this.applyPlugins({
        name: 'modifyRunnerOpts',
        opts: {
          opts: opts?.config
        }
      })
    }

    await this.applyPlugins({
      name,
      opts
    })
  }

this.initPresetsAndPlugins() 函数 下节解析

接着看插件注册:

8.kernel ctx.registerCommand 注册 init 命令

// packages/taro-cli/src/presets/commands/init.ts
import type { IPluginContext } from '@tarojs/service'

export default (ctx: IPluginContext) => {
  ctx.registerCommand({
    name: 'init',
    optionsMap: {
      '--name [name]': '项目名称',
      '--description [description]': '项目介绍',
      '--typescript': '使用TypeScript',
      '--npm [npm]': '包管理工具',
      '--template-source [templateSource]': '项目模板源',
      '--clone [clone]': '拉取远程模板时使用git clone',
      '--template [template]': '项目模板',
      '--css [css]': 'CSS预处理器(sass/less/stylus/none)',
      '-h, --help': 'output usage information'
    },
    async fn (opts) {
      // init project
      const { appPath } = ctx.paths
      const { options } = opts
      const { projectName, templateSource, clone, template, description, typescript, css, npm, framework, compiler, hideDefaultTemplate, sourceRoot } = options
      const Project = require('../../create/project').default
      const project = new Project({
        sourceRoot,
        projectName,
        projectDir: appPath,
        npm,
        templateSource,
        clone,
        template,
        description,
        typescript,
        framework,
        compiler,
        hideDefaultTemplate,
        css
      })

      project.create()
    }
  })
}

通过ctx.registerCommand注册了一个name为init的命令,会存入到内核Kernel实例对象的hooks属性中,其中ctx就是kernel的实例对象。具体实现是fn函数。

9.kernel.applyPlugins触发插件

// packages/taro-service/src/Kernel.ts
async applyPlugins (args: string | { name: string, initialVal?: any, opts?: any }) {
    let name
    let initialVal
    let opts
    if (typeof args === 'string') {
      name = args
    } else {
      name = args.name
      initialVal = args.initialVal
      opts = args.opts
    }
    this.debugger('applyPlugins')
    this.debugger(`applyPlugins:name:${name}`)
    this.debugger(`applyPlugins:initialVal:${initialVal}`)
    this.debugger(`applyPlugins:opts:${opts}`)
    if (typeof name !== 'string') {
      throw new Error('调用失败,未传入正确的名称!')
    }
    // 省略部分代码
}

上半部分,主要是适配两种传参的方式。

// packages/taro-service/src/Kernel.ts
async applyPlugins (args: string | { name: string, initialVal?: any, opts?: any }) {
    // 省略部分代码
    const hooks = this.hooks.get(name) || []
    if (!hooks.length) {
      return await initialVal
    }
    const waterfall = new AsyncSeriesWaterfallHook(['arg'])
    if (hooks.length) {
      const resArr: any[] = []
      for (const hook of hooks) {
        waterfall.tapPromise({
          name: hook.plugin!,
          stage: hook.stage || 0,
          // @ts-ignore
          before: hook.before
        }, async arg => {
          const res = await hook.fn(opts, arg)
          if (IS_MODIFY_HOOK.test(name) && IS_EVENT_HOOK.test(name)) {
            return res
          }
          if (IS_ADD_HOOK.test(name)) {
            resArr.push(res)
            return resArr
          }
          return null
        })
      }
    }
    return await waterfall.promise(initialVal)
}

Taro 的插件架构基于Tapable

这里使用了AsyncSeriesWaterfallHook

简单来说就是异步或者同步方法串联起来,上一个结果作为下一个函数的参数依次执行。

通过 ctx.register 注册过的钩子需要通过方法 ctx.applyPlugins 进行触发。

官网文档描述:插件方法

10.kernel.runHelp 命令帮助信息

在kernel.run 函数中,有一个opts.isHelp的判断,执行kernel.runHelp方法


    // packages/taro-service/src/Kernel.ts
    if (opts?.isHelp) {
      return this.runHelp(name)
    }

runHelp具体代码实现如下:

  runHelp (name: string) {
    const command = this.commands.get(name)
    const defaultOptionsMap = new Map()
    defaultOptionsMap.set('-h, --help', 'output usage information')
    let customOptionsMap = new Map()
    if (command?.optionsMap) {
      customOptionsMap = new Map(Object.entries(command?.optionsMap))
    }
    const optionsMap = new Map([...customOptionsMap, ...defaultOptionsMap])
    printHelpLog(name, optionsMap, command?.synopsisList ? new Set(command?.synopsisList) : new Set())
  }

根据name从this.commands 的Map中获取到命令,输出对应的optionsMap和synopsisList。

$taro init --help

总结:

  1. 回顾了两种调试方式。command方式、.vscode/launch.json。
  2. taro入口 taro-cli具体实现
  3. taro-cli命令实现原理,读取用户配置文件和全局配置文件
  4. 使用minimist命令行解析参数工具,设置环境变量,通过dotenvdotenv-expand解析.env等变量,将.env的文件中环境变量加载到process.env中。并注册命令。
  5. taro-service Config和Kernel(内核)等解耦实现。taro单独抽离了一个taro-service模块,包含Config、Kernel内核、Plugin等。
  6. Taro 插件机制,学会了如何编写一个taro插件。
  7. Taro 的插件架构基于TapableAsyncSeriesWaterfallHook将异步和同步函数串联起来执行,实现各个插件分别在不同的地方,达到解耦效果。

完结。

此文章为2024年06月Day1源码共读,生活在阴沟里,也要记得仰望星空。