Taro 源码共读 - @tarojs/cli

556 阅读5分钟

Taro 是一个开放源代码的多端统一开发框架,旨在帮助开发者通过一次编码实现多端应用(如微信小程序、支付宝小程序、H5、React Native 等)的开发。为了更好地理解 Taro 的源码,这里将简要介绍其核心原理和主要组成部分。
核心原理
Taro 的核心原理是通过编译器将代码转换为不同平台的代码。开发者可以使用 React 语法编写代码,然后通过 Taro 编译工具将其转换为目标平台的代码。

项目分析

首先,我们需要clone相应的仓库源代码,并查看贡献指南。在指南中详细说明了安装依赖的步骤,并提供了单点调试的操作方法
接下来,让我们浏览一下目录结构,在packages文件夹里有许多包,如何开始分析呢?

实际上,官方文档已经提供了对仓库的概述。

路径描述
@tarojs/cliCLI 工具
@tarojs/service插件化内核
@tarojs/taro-loaderWebpack loaders
@tarojs/helper工具库,主要供 CLI、编译时使用
@tarojs/runner-utils工具库,主要供小程序、H5 的编译工具使用
@tarojs/shared工具库,主要供运行时使用
@tarojs/taro暴露各端所需要的 Taro 对象
@tarojs/api和各端相关的 Taro API
babel-preset-taroBabel preset
eslint-config-taroESLint 规则
postcss-pxtransformPostCSS 插件,转换 px 为各端的自适应尺寸单位
postcss-html-transformPostCSS 插件,用于 HTML、小程序标签的类名相互转换

断点调试

文档中有详细的断点调式方法,这里列出简要步骤

  1. 首先我们安装依赖并编译项目
pnpm i
pnpm build
  1. 打开.vscode/launch.json
  • 这里以编译微信小程序为例 examples/mini-program-example
  • 修改命令
  • 打上断点
    • 因为有sourceMap,我们会调到源码中
// lauch.json
{
  "configurations": [
    {
      // ...
      "cwd": "这是设置成将要调试的项目 examples/mini-program-example",
      "args": ["build", "--type", "weapp", "--watch"]
    }
  ]
}

执行了这行命令是做什么,内部执行了什么操作?

读取环境变量

首先,创建了一个CLI对象,这个对象里的run函数只做了一件事,解析参数。

我们进入函数看看。
首先,会获取当前的环境变量,根据传入的参数,去读对应的配置文件。

创建内核

然后,创建一个Kernal内核,那么这个内核是做什么的呢。我们等下再看。
创建完内核,将不同的插件传递给kernal

这里有一个疑问,插件是什么,为什么要以插件的形式传递给内核?

我们在创建Kernal的时候,打上断点,调到packages/taro-service/src/Kernel.ts中。

export default class Kernel extends EventEmitter {
  // ...
}

这个类继承自EventEmitter,应该是用来注册和监听事件的功能。
回到cli.ts,我们看下传递完参数,执行了kernal的什么方法。

插件注册

  • 执行了kernel.run
// packages/taro-cli/src/commands/customCommand.ts
export default function customCommand (
  command: string,
  kernel: Kernel,
  args: { _: string[], [key: string]: any }
) {
  if (typeof command === 'string') {
    // 省略代码...
    
    // 执行了 run 函数
    kernel.run({
      name: command,
      opts: {
        _: args._,
        options,
        isHelp: args.h
      }
    })
  }
}

接下来,看下run函数的

async run(args) {
  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);

  // 初始化预设和插件
  this.debugger('initPresetsAndPlugins');
  this.initPresetsAndPlugins();

  // 执行 'onReady' 插件
  await this.applyPlugins('onReady');

  // 记录调试信息并执行 'onStart' 插件
  this.debugger('command:onStart');
  await this.applyPlugins('onStart');

  // 检查命令是否存在
  if (!this.commands.has(name)) {
    throw new Error(`${name} 命令不存在`);
  }

  // 如果选项中包含 isHelp 属性,则运行帮助命令
  if (opts?.isHelp) {
    return this.runHelp(name);
  }

  // 如果选项中包含平台信息,则处理平台选项
  if (opts?.options?.platform) {
    // 根据平台运行并获取配置
    opts.config = this.runWithPlatform(opts.options.platform);
    
    // 执行 'modifyRunnerOpts' 插件,并传入更新后的选项
    await this.applyPlugins({
      name: 'modifyRunnerOpts',
      opts: {
        opts: opts?.config
      }
    });
  }

  // 执行命令对应的插件
  await this.applyPlugins({
    name,
    opts
  });
}

我们重点关注的是这句代码:this.initPresetsAndPlugins();
这个函数主要做了以下功能

  • 获取初始配置和全局配置。
  • 合并命令行选项和项目配置中的插件和预设。
  • 将全局配置中的插件和预设转换为对象。
  • 在非测试环境中创建一个 swc 注册器,只包括合并后的插件和预设。
  • 初始化 plugins 为一个空的 Map 对象。
  • 初始化额外插件的配置对象。
  • 解析合并后的插件和预设。
initPresetsAndPlugins () {
    const initialConfig = this.initialConfig
    const initialGlobalConfig = this.initialGlobalConfig
  
    // 合并presets 
    const cliAndProjectConfigPresets = mergePlugins(this.optsPresets || [], initialConfig.presets || [])()
    const cliAndProjectPlugins = mergePlugins(this.optsPlugins || [], initialConfig.plugins || [])()
    
    // 将全局配置中的 plugins 转换为对象
    const globalPlugins = convertPluginsToObject(initialGlobalConfig.plugins || [])()
    // 将全局配置中的 presets 转换为对象
    const globalPresets = convertPluginsToObject(initialGlobalConfig.presets || [])()
    
    // 调试信息:输出初始化的 presets 和 plugins 配置
    this.debugger('initPresetsAndPlugins', cliAndProjectConfigPresets, cliAndProjectPlugins)
    this.debugger('globalPresetsAndPlugins', globalPlugins, globalPresets)
  
    // 如果当前环境不是测试环境,则创建 swc 注册器
    // https://www.npmjs.com/package/@swc/core
    // rust 编写
    // 编译速度更快  对应的性能测试
    // https://docs.taro.zone/en/blog/2022/05/19/Taro-3.5-beta/#3-%E6%8F%90%E9%80%9F%E6%95%88%E6%9E%9C
    // https://swc.rs/docs/benchmarks
    process.env.NODE_ENV !== 'test' &&
    helper.createSwcRegister({
      only: [
        // 仅包括所有合并后的 plugins 和 presets 的键
        ...Object.keys(cliAndProjectConfigPresets),
        ...Object.keys(cliAndProjectPlugins),
        ...Object.keys(globalPresets),
        ...Object.keys(globalPlugins)
      ]
    })
    // 初始化 plugins 为一个 Map 对象
    this.plugins = new Map()
    // 初始化额外的插件配置对象
    this.extraPlugins = {}
    this.globalExtraPlugins = {}
    // 解析合并后的 presets
    this.resolvePresets(cliAndProjectConfigPresets, globalPresets)
    // 解析合并后的 plugins
    this.resolvePlugins(cliAndProjectPlugins, globalPlugins)
}

这一步是将配置中的预设和插件收集起来,等待交给下一步操作

那这些插件的作用是什么? 怎么编写这些插件 (下一章节) docs.taro.zone/docs/plugin…

我们在文档中可以得知插件的固定写法

export default (ctx, options) => {
  // plugin 主体
  ctx.onBuildStart(() => {
    console.log('编译开始!')
  })
  ctx.onBuildFinish(() => {
    console.log('Webpack 编译结束!')
  })
  ctx.onBuildComplete(() => {
    console.log('Taro 构建完成!')
  })
}

插件在编译过程中注入需要执行的函数,以拓展编译器的功能,例如代码转换、代码检查、文件处理和服务器上传。

插件执行

接下来,我们看下怎么执行这些插件的

在这里,使用了AsyncSeriesWaterfallHook方法来创建异步流水线,该方法可以按顺序执行插件。
你可以在github.com/webpack/tap…找到这个库,它用于创建执行的流水线。

总结

通过上述调试,我们已了解Taro CLI的功能及项目调试方法,并学会了如何配置launch.json
目前,我们已成功解决以下问题:

当执行 taro build --type weapp,Taro做了什么

  • 读取环境配置,设置变量(dotenv)
  • 创建 kernel,执行 kernel.run
  • 通过创建一个 Map 来存储 Plugins 和 Presets
  • 通过创建一个流水线来实现插件的顺序执行

Taro源码中怎么实现提高编译速度

  • 使用了 swc 代替原来的 babel 来完成编译

Taro中的插件怎么实现按顺序执行

  • 使用了tapable库来创建流水线,实现了按顺序执行插件