1. 前言

大家好,我是若川,欢迎关注我的公众号:若川视野。我倾力持续组织了 3 年多每周大家一起学习 200 行左右的源码共读活动,感兴趣的可以点此扫码加我微信 ruochuan02 参与。另外,想学源码,极力推荐关注我写的专栏《学习源码整体架构系列》,目前是掘金关注人数(6k+人)第一的专栏,写有几十篇源码文章。

截至目前(2024-07-17),taro 正式版是 3.6.34Taro 4.0 Beta 发布:支持开发鸿蒙应用、小程序编译模式、Vite 编译等。文章提到将于 2024 年第二季度,发布 4.x。目前已经发布 4.x。所以我们直接学习 main 分支最新版本是 4.0.2

计划写一个 Taro 源码揭秘系列,博客地址:https://ruochuan12.github.io/taro 可以加入书签,持续关注若川

学完本文,你将学到:

1. taro init 初始化项目,背后原理是什么?
2. 如何调试 taro cli init 源码
3. nodejs 如何调用 rust 代码?
4. 如何调试 rust 代码
5. 如何使用 handlebars 模板引擎
等等

关于克隆项目、环境准备、如何调试代码等,参考第一篇文章-准备工作、调试。后续文章基本不再过多赘述。

文章中基本是先放源码,源码中不做过多解释。源码后面再做简单讲述。

众所周知,我们最开始初始化项目时都是使用 taro init 命令,本文我们继续来学习这个命令是如何实现的。

我们可以通过 npm-dist-tag 文档 命令来查看 @tarojs/cli 包的所有 tag 版本。

npm dist-tag @tarojs/cli

如图所示:

taro-cli-npm-dist-tag.png

目前 latest 标签(默认版本)是 3.6.34next 标签是 4.0.0。后续 latest 标签会设置为 4.x 版本。

我们先用 @tarojs/cli@next 初始化一个项目看看。全局安装相对麻烦,我们不全局安装,使用 npx 来运行 next tag 版本。

npx @tarojs/cli@next init taro4-next

这个初始化完整的过程,我用 GIPHY CAPTURE 工具录制了一个gif,如下图所示:

taro-init-gif-high.gif

我们接下来就是一步步来分析这个 gif 中的每一个步骤的实现原理。

2. 调试 taro init

我们在 .vscode/launch.json 中的原有的 CLI debug 命令行调试配置,添加 init 配置如下:

// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [{
      "type": "node",
      "request": "launch",
      "name": "CLI debug",
      "program": "${workspaceFolder}/packages/taro-cli/bin/taro",
+     "console": "integratedTerminal",
+     "args": [
+       "init",
+       "taro-init-test",
+     ],
      // 省略若干代码...
      "skipFiles": ["<node_internals>/**"]
    }]
}

其中 "console": "integratedTerminal", 配置是为了在调试时,可以在终端输入和交互。

3. init 命令行 fn 函数

根据前面两篇 1. taro cli init2. taro 插件机制 文章,我们可以得知:taro init 初始化命令,最终调用的是 packages/taro-cli/src/presets/commands/init.ts 文件中的 ctx.registerCommand 注册的 init 命令行的 fn 函数。

// 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 } = options
      const Project = require('../../create/project').default
      const project = new Project({
        // 省略若干参数...
      })

      project.create()
    }
  })
}

fn 函数,其中 options 参数是 init 命令行中的所有参数。
主要做了如下几件事:

  • 读取组合各种参数,初始化 project 对象,并调用 create 方法。

我们重点来看 packages/taro-cli/src/create/project.tsProject 类的实现,和 create 方法。

4. new Project 构造函数

// packages/taro-cli/src/create/project.ts
export default class Project extends Creator {
  public rootPath: string
  public conf: IProjectConfOptions

  constructor (options: IProjectConfOptions) {
    super(options.sourceRoot)
    const unSupportedVer = semver.lt(process.version, 'v18.0.0')
    if (unSupportedVer) {
      throw new Error('Node.js 版本过低,推荐升级 Node.js 至 v18.0.0+')
    }
    this.rootPath = this._rootPath

    this.conf = Object.assign(
      {
        projectName: '',
        projectDir: '',
        template: '',
        description: '',
        npm: ''
      },
      options
    )
  }
}

Project 继承了 Creator 类。

构造函数中,使用 semver.lt 判断当前 node 版本是否低于 v18.0.0,如果低于则报错。
semver 是一个版本号比较库,可以用来判断 node 版本是否符合要求。

其次就是初始化 this.rootPaththis.conf

我们继续来看 Creator 类,构造函数中调用了 init 方法。

// packages/taro-cli/src/create/creator.ts
export default class Creator {
  protected _rootPath: string
  public rootPath: string

  constructor (sourceRoot?: string) {
    this.rootPath = this.sourceRoot(sourceRoot || path.join(getRootPath()))
    this.init()
  }
}

所以继续来看 init 方法。

// packages/taro-cli/src/create/project.ts
init () {
    clearConsole()
    console.log(chalk.green('Taro 即将创建一个新项目!'))
    console.log(`Need help? Go and open issue: ${chalk.blueBright('https://tls.jd.com/taro-issue-helper')}`)
    console.log()
}

调试截图如下:

调试截图

输出就是这个图:

taro-init-0.png

其中👽 Taro v4.0.0 输出的是 tarojs-cli/package.json 的版本,第一篇文章 4. taro-cli/src/utils/index.ts 中有详细讲述,这里就不再赘述了。

输出获取 taro 全局配置成功是指获取 ~/.taro-global-config/index.json 文件的插件集 presets 和插件 plugins第一篇文章 6.2.2 config.initGlobalConfig 初始化全局配置中有详细讲述,spinner.succeed('获取 taro 全局配置成功') 这里就不再赘述了。

看完了 Project 构造函数,我们来看 Project 类的 create 方法。

4.1 project.create 创建项目

// packages/taro-cli/src/create/project.ts
async create () {
    try {
        const answers = await this.ask()
        const date = new Date()
        this.conf = Object.assign(this.conf, answers)
        this.conf.date = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
        this.write()
    } catch (error) {
        console.log(chalk.red('创建项目失败: ', error))
    }
}

create 函数主要做了以下几件事:

  • 调用 ask 询问用户输入项目名称、描述、CSS预处理器、包管理工具等。
  • 把用户反馈的结果和之前的配置合并起来,得到 this.conf
  • 调用 write 方法,写入文件,初始化模板项目。

调试截图如下:

taro-init-debugger-create.png

this.conf 参数结果如下:

const conf = {
  projectName: "taro-init-test",
  projectDir: "/Users/ruochuan/git-source/github/taro",
  template: "default",
  description: "taro",
  npm: "Yarn",
  templateSource: "direct:https://gitee.com/o2team/taro-project-templates.git#v4.0",
  clone: false,
  typescript: true,
  framework: "React",
  compiler: "Webpack5",
  hideDefaultTemplate: undefined,
  css: "Sass",
  date: "2024-7-12",
}

我们来看 ask 方法。

5. ask 询问用户输入项目名称、描述等

// packages/taro-cli/src/create/project.ts
async ask () {
    let prompts: Record<string, unknown>[] = []
    const conf = this.conf

    this.askProjectName(conf, prompts)
    this.askDescription(conf, prompts)
    this.askFramework(conf, prompts)
    this.askTypescript(conf, prompts)
    this.askCSS(conf, prompts)
    this.askCompiler(conf, prompts)
    this.askNpm(conf, prompts)
    await this.askTemplateSource(conf, prompts)

    const answers = await inquirer.prompt<IProjectConf>(prompts)

    prompts = []
    const templates = await this.fetchTemplates(answers)
    await this.askTemplate(conf, prompts, templates)
    const templateChoiceAnswer = await inquirer.prompt<IProjectConf>(prompts)

    return {
      ...answers,
      ...templateChoiceAnswer
    }
  }

简单来说 ask 方法就是一系列的 inquirer 交互。

inquirer 是一个命令行交互库,可以用来创建命令行程序。

如果参数中没指定相应参数,那么就询问:

  • 项目名称
  • 项目介绍
  • 选择框架(React、PReact、Vue3、Solid
  • 是否启用TS
  • CSS预处理器(Sass、less、Stylus、无等
  • 编译工具(webpack、vite
  • 包管理工具(npm、yarn、pnpm
  • 选择模板源(gitee最快、github最新、CLI 内置模板等
  • 选择模板(默认模板等
  • 等等

如图所示:

初始化

我们重点讲述以下几个方法

  • askProjectName 询问项目名称
  • askTemplateSource 询问模板源
  • fetchTemplates 获取模板列表
  • askTemplate 询问模板

我们来看第一个 askProjectName 方法。

5.1 askProjectName 询问项目名称

askProjectName: AskMethods = function (conf, prompts) {
    if ((typeof conf.projectName) !== 'string') {
      prompts.push({
        type: 'input',
        name: 'projectName',
        message: '请输入项目名称!',
        validate (input) {
          if (!input) {
            return '项目名不能为空!'
          }
          if (fs.existsSync(input)) {
            return '当前目录已经存在同名项目,请换一个项目名!'
          }
          return true
        }
      })
    } else if (fs.existsSync(conf.projectName!)) {
      prompts.push({
        type: 'input',
        name: 'projectName',
        message: '当前目录已经存在同名项目,请换一个项目名!',
        validate (input) {
          if (!input) {
            return '项目名不能为空!'
          }
          if (fs.existsSync(input)) {
            return '项目名依然重复!'
          }
          return true
        }
      })
    }
  }

后面的 askDescriptionaskFrameworkaskFrameworkaskTypescriptaskCSSaskCompileraskNpm,都是类似方法,就不再赘述了。

5.2 askTemplateSource 询问模板源

// packages/taro-cli/src/create/project.ts
import {
  chalk,
  DEFAULT_TEMPLATE_SRC,
  DEFAULT_TEMPLATE_SRC_GITEE,
  fs,
  getUserHomeDir,
  SOURCE_DIR,
  TARO_BASE_CONFIG,
  TARO_CONFIG_FOLDER
} from '@tarojs/helper'

导出的就是这些常量。

// packages/taro-helper/src/constants.ts
export const DEFAULT_TEMPLATE_SRC = 'github:NervJS/taro-project-templates#v4.0'
export const DEFAULT_TEMPLATE_SRC_GITEE = 'direct:https://gitee.com/o2team/taro-project-templates.git#v4.0'
export const TARO_CONFIG_FOLDER = '.taro3.7'
export const TARO_BASE_CONFIG = 'index.json'
export const TARO_GLOBAL_CONFIG_DIR = '.taro-global-config'
export const TARO_GLOBAL_CONFIG_FILE = 'index.json'
// packages/taro-cli/src/create/project.ts
askTemplateSource: AskMethods = async function (conf, prompts) {
    if (conf.template === 'default' || conf.templateSource) return

    const homedir = getUserHomeDir()
    const taroConfigPath = path.join(homedir, TARO_CONFIG_FOLDER)
    const taroConfig = path.join(taroConfigPath, TARO_BASE_CONFIG)

    let localTemplateSource: string

    // 检查本地配置
    if (fs.existsSync(taroConfig)) {
      // 存在则把模板源读出来
      const config = await fs.readJSON(taroConfig)
      localTemplateSource = config?.templateSource
    } else {
      // 不存在则创建配置
      await fs.createFile(taroConfig)
      await fs.writeJSON(taroConfig, { templateSource: DEFAULT_TEMPLATE_SRC })
      localTemplateSource = DEFAULT_TEMPLATE_SRC
    }
    const choices = [
        // 省略,拆分放到下方
    ];
    if (localTemplateSource && localTemplateSource !== DEFAULT_TEMPLATE_SRC && localTemplateSource !== DEFAULT_TEMPLATE_SRC_GITEE) {
      choices.unshift({
        name: `本地模板源:${localTemplateSource}`,
        value: localTemplateSource
      })
    }
    // 省略部分代码,拆分放到下方
  }

简单来说:

  • 就是判断本地是否存在配置 ~/.taro3.7/index.json,如果存在则读取模板源,如果不存在则创建配置。创建配置时,默认模板源为 github:NervJS/taro-project-templates#v4.0
  • 另外,如果本地模板源不是默认模板源,那么就把本地模板源作为选项,放在最前面,供用户选择。

其中,~/.taro3.7/index.json 内容格式如下:

// ~/.taro3.7/index.json
{
    "remoteSchemaUrl": "https://raw.githubusercontent.com/NervJS/taro-doctor/main/assets/config_schema.json",
    "useRemoteSchema": true
}
// packages/taro-cli/src/create/project.ts
const choices = [
  {
    name: 'Gitee(最快)',
    value: DEFAULT_TEMPLATE_SRC_GITEE
  },
  {
    name: 'Github(最新)',
    value: DEFAULT_TEMPLATE_SRC
  },
  {
    name: 'CLI 内置默认模板',
    value: 'default-template'
  },
  {
    name: '自定义',
    value: 'self-input'
  },
  {
    name: '社区优质模板源',
    value: 'open-source'
  }
]

// 省略部分代码本地模板源的判断,在上方已经展示。

prompts.push({
  type: 'list',
  name: 'templateSource',
  message: '请选择模板源',
  choices
}, {
  type: 'input',
  name: 'templateSource',
  message: '请输入模板源!',
  askAnswered: true,
  when (answers) {
    return answers.templateSource === 'self-input'
  }
}, {
  type: 'list',
  name: 'templateSource',
  message: '请选择社区模板源',
  async choices (answers) {
    const choices = await getOpenSourceTemplates(answers.framework)
    return choices
  },
  askAnswered: true,
  when (answers) {
    return answers.templateSource === 'open-source'
  }
})
// packages/taro-cli/src/create/project.ts
async ask () {
    // 省略上半部分代码
    const answers = await inquirer.prompt<IProjectConf>(prompts)

    prompts = []
    const templates = await this.fetchTemplates(answers)
    await this.askTemplate(conf, prompts, templates)
    const templateChoiceAnswer = await inquirer.prompt<IProjectConf>(prompts)

    return {
      ...answers,
      ...templateChoiceAnswer
    }
}

我们继续来看 fetchTemplates 函数:

5.3 fetchTemplates 获取模板列表

// packages/taro-cli/src/create/project.ts
async fetchTemplates (answers: IProjectConf): Promise<ITemplates[]> {
  const { templateSource, framework, compiler } = answers
  this.conf.framework = this.conf.framework || framework || ''
  this.conf.templateSource = this.conf.templateSource || templateSource

  // 使用默认模版
  if (answers.templateSource === 'default-template') {
    this.conf.template = 'default'
    answers.templateSource = DEFAULT_TEMPLATE_SRC_GITEE
  }
  if (this.conf.template === 'default' || answers.templateSource === NONE_AVAILABLE_TEMPLATE) return Promise.resolve([])

  // 从模板源下载模板
  const isClone = /gitee/.test(this.conf.templateSource) || this.conf.clone
  const templateChoices = await fetchTemplate(this.conf.templateSource, this.templatePath(''), isClone)

  const filterFramework = (_framework) => {
    const current = this.conf.framework?.toLowerCase()

    if (typeof _framework === 'string' && _framework) {
      return current === _framework.toLowerCase()
    } else if (isArray(_framework)) {
      return _framework?.map(name => name.toLowerCase()).includes(current)
    } else {
      return true
    }
  }

  const filterCompiler = (_compiler) => {
    if (_compiler && isArray(_compiler)) {
      return _compiler?.includes(compiler)
    }
    return true
  }

  // 根据用户选择的框架筛选模板
  const newTemplateChoices: ITemplates[] = templateChoices
    .filter(templateChoice => {
      const { platforms, compiler } = templateChoice
      return filterFramework(platforms) && filterCompiler(compiler)
    })

  return newTemplateChoices
}

我们继续来看 fetchTemplate 函数,它主要做了以下几件事情:

5.3.1 fetchTemplate 获取模板

// packages/taro-cli/src/create/fetchTemplate.ts
import * as path from 'node:path'

import { chalk, fs } from '@tarojs/helper'
import * as AdmZip from 'adm-zip'
import axios from 'axios'
import * as download from 'download-git-repo'
import * as ora from 'ora'

import { getTemplateSourceType, readDirWithFileTypes } from '../util'
import { TEMPLATE_CREATOR } from './constants'

export interface ITemplates {
  name: string
  value: string
  platforms?: string | string[]
  desc?: string
  compiler?: string[]
}

const TEMP_DOWNLOAD_FOLDER = 'taro-temp'

export default function fetchTemplate (templateSource: string, templateRootPath: string, clone?: boolean): Promise<ITemplates[]> {
  const type = getTemplateSourceType(templateSource)
  const tempPath = path.join(templateRootPath, TEMP_DOWNLOAD_FOLDER)
  let name: string
  // eslint-disable-next-line no-async-promise-executor
  return new Promise<void>(async (resolve) => {
    // 下载文件的缓存目录
    if (fs.existsSync(tempPath)) await fs.remove(tempPath)
    await fs.mkdir(tempPath)

    const spinner = ora(`正在从 ${templateSource} 拉取远程模板...`).start()

    if (type === 'git') {
      name = path.basename(templateSource)
      download(templateSource, path.join(tempPath, name), { clone }, async error => {
        if (error) {
          console.log(error)
          spinner.color = 'red'
          spinner.fail(chalk.red('拉取远程模板仓库失败!'))
          await fs.remove(tempPath)
          return resolve()
        }
        spinner.color = 'green'
        spinner.succeed(`${chalk.grey('拉取远程模板仓库成功!')}`)
        resolve()
      })
    } else if (type === 'url') {
      // 省略这部分代码...
      // 如果是 `url` 则用 `axios` 下载
    }
  }).then(async () => {
    // 拆解到下方讲述
  })
}

这个方法主要做了以下几件事情:

  • 判断模板来源地址是 git 类型,那么使用 download-git-repo 下载远程仓库到本地。
  • 判断模板来源地址是 git 类型,那么则用 axios 下载。

then 部分

// packages/taro-cli/src/create/fetchTemplate.ts
// then 部分
const templateFolder = name ? path.join(tempPath, name) : ''

// 下载失败,只显示默认模板
if (!fs.existsSync(templateFolder)) return Promise.resolve([])

const isTemplateGroup = !(
  fs.existsSync(path.join(templateFolder, 'package.json')) ||
  fs.existsSync(path.join(templateFolder, 'package.json.tmpl'))
)

if (isTemplateGroup) {
  // 模板组
  const files = readDirWithFileTypes(templateFolder)
    .filter(file => !file.name.startsWith('.') && file.isDirectory && file.name !== '__MACOSX')
    .map(file => file.name)
  await Promise.all(
    files.map(file => {
      const src = path.join(templateFolder, file)
      const dest = path.join(templateRootPath, file)
      return fs.move(src, dest, { overwrite: true })
    })
  )
  await fs.remove(tempPath)

  const res: ITemplates[] = files.map(name => {
    const creatorFile = path.join(templateRootPath, name, TEMPLATE_CREATOR)

    if (!fs.existsSync(creatorFile)) return { name, value: name }
    const { name: displayName, platforms = '', desc = '', compiler } = require(creatorFile)

    return {
      name: displayName || name,
      value: name,
      platforms,
      compiler,
      desc
    }
  })
  return Promise.resolve(res)
} else {
  // 单模板
  // 省略这部分代码,单模版和模板组逻辑基本一致,只是一个是多个一个是单个
}

这段代码主要做了以下几件事情:

  • 判断是否是模板组,如果是模板组,则遍历 packages/taro-cli/templates/taro-temp 文件夹下的所有文件夹,并移动到 packages/taro-cli 目录下的 templates 文件夹。
  • 不是模板组,则直接移动到 packages/taro-cli/templates/taro-temp 目录下单个模板到 templates 文件夹。

用一张图来展示:

合并

5.4 askTemplate 询问用户选择模板

askTemplate: AskMethods = function (conf, prompts, list = []) {
    const choices = list.map(item => ({
      name: item.desc ? `${item.name}(${item.desc})` : item.name,
      value: item.value || item.name
    }))

    if (!conf.hideDefaultTemplate) {
      choices.unshift({
        name: '默认模板',
        value: 'default'
      })
    }

    if ((typeof conf.template as 'string' | undefined) !== 'string') {
      prompts.push({
        type: 'list',
        name: 'template',
        message: '请选择模板',
        choices
      })
    }
  }

6. write 写入项目

// packages/taro-cli/src/create/project.ts
write (cb?: () => void) {
    this.conf.src = SOURCE_DIR
    const { projectName, projectDir, template, autoInstall = true, framework, npm } = this.conf as IProjectConf
    // 引入模板编写者的自定义逻辑
    // taro/packages/taro-cli/templates/default
    const templatePath = this.templatePath(template)
    // taro/packages/taro-cli/templates/default/template_creator.js
    const handlerPath = path.join(templatePath, TEMPLATE_CREATOR)
    const handler = fs.existsSync(handlerPath) ? require(handlerPath).handler : {}
    createProject({
      projectRoot: projectDir,
      projectName,
      template,
      npm,
      framework,
      css: this.conf.css || CSSType.None,
      autoInstall: autoInstall,
      templateRoot: getRootPath(),
      version: getPkgVersion(),
      typescript: this.conf.typescript,
      date: this.conf.date,
      description: this.conf.description,
      compiler: this.conf.compiler,
      period: PeriodType.CreateAPP,
    }, handler).then(() => {
      cb && cb()
    })
}

write 函数主要做了以下几件事情:

  • 获取用户输入的参数,包括项目名称、项目目录、模板名称等。
  • 引入模板编写者的自定义逻辑。
  • 调用 createProject 函数,传入用户输入的参数和模板编写者的自定义逻辑。

调试截图

taro-init-debugger-write.png

6.1 template\_creator.js 默认模板中创建模板的自定义逻辑

// packages/taro-cli/templates/default/template_creator.js
const path = require('path')

function createWhenTs (err, params) {
  return !!params.typescript
}

function normalizePath (path) {
  return path.replace(/\\/g, '/').replace(/\/{2,}/g, '/')
}

const SOURCE_ENTRY = '/src'
const PAGES_ENTRY = '/src/pages'

const handler = {
  '/tsconfig.json': createWhenTs,
  '/types/global.d.ts': createWhenTs,
  '/types/vue.d.ts' (err, { framework, typescript }) {
    return ['Vue3'].includes(framework) && !!typescript
  },
  '/src/pages/index/index.jsx' (err, { pageDir = '', pageName = '', subPkg = '' }) {
    return {
      setPageName: normalizePath(path.join(PAGES_ENTRY, pageDir, pageName, 'index.jsx')),
      setSubPkgName: normalizePath(path.join(SOURCE_ENTRY, subPkg, pageDir, pageName, 'index.jsx'))
    }
  },
  // 省略部分代码
  '/_editorconfig' () {
    return { setPageName: `/.editorconfig` }
  },
  '/_env.development' () {
    return { setPageName: `/.env.development` }
  },
  '/_env.production' () {
    return { setPageName: `/.env.production` }
  },
  '/_env.test' () {
    return { setPageName: `/.env.test` }
  },
  '/_eslintrc' () {
    return { setPageName: `/.eslintrc` }
  },
  '/_gitignore' () {
    return { setPageName: `/.gitignore` }
  }
}

const basePageFiles = [
  '/src/pages/index/index.jsx',
  '/src/pages/index/index.vue',
  '/src/pages/index/index.css',
  '/src/pages/index/index.config.js'
]

module.exports = {
  handler,
  basePageFiles
}

template_creator.js 文件中的 handler 对象,定义了模板中创建的文件和自定义逻辑。
比如当 !!params.typescript 的时候,创建 /tsconfig.jsontypes/global.d.ts 文件。
\['Vue3'].includes(framework) && !!typescript 的时候,创建 types/vue.d.ts 文件。
根据 /\_env.development 文件创建 .env.development
等等

因为在一些场景下,. 开头文件会出现问题,所以改用 _ 开头命名文件,创建时做一次替换。

7. 调试 rust 代码

我们从 write 函数调用 createProject 函数,可以看到 createProject 等是从 @tarojs/binding 引入的。

import { CompilerType, createProject, CSSType, FrameworkType, NpmType, PeriodType } from '@tarojs/binding'

简单来说就是:通过 napi-rscreate_project 函数暴露给 nodejs ,然后通过 nodejs 调用 rustcreate_project 函数。

关于具体细节,用 rust 改造 taro init 这部分代码的作者 @luckyadam,写了一篇文章。可以参考学习解锁前端新潜能:如何使用 Rust 锈化前端工具链,我在这里就不赘述了。

安装 VSCode 插件 rust-analyzer (方便跳转代码定义等) 和调试代码的插件 CodeLLDB

更多 rust 学习,可参考 rust 官网:rust-lang.org

我们在 .vscode/launch.json 中的原有的 debug-init 命令行调试配置,修改 "type": "lldb", 配置如下:

// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
-     "type": "node",
+     "type": "lldb",
      "request": "launch",
      "name": "debug-init",
      "sourceLanguages": ["rust"],
      "program": "node",
      "args": ["${workspaceFolder}/packages/taro-cli/bin/taro", "init", "test_pro"],
      "cwd": "${workspaceFolder}",
      "preLaunchTask": "build binding debug",
      "postDebugTask": "remove test_pro"
    }]
}

这样我们就可以在 crates/native_binding/src/lib.rs 文件中打断点调试了。

调试截图如下:

taro-init-debugger-rust.png

我们继续来看 crates/native_binding/src/lib.rs 文件中的 create_projectnodejs 中调用则是 createProject )函数:

8. rust create\_project 创建项目

// crates/native_binding/src/lib.rs
#[napi]
pub async fn create_project(
  conf: Project,
  handlers: HashMap<String, ThreadsafeFunction<CreateOptions>>,
) -> Result<()> {
  let project: Project = Project::new(
    conf.project_root,
    conf.project_name,
    conf.npm,
    conf.description,
    conf.typescript,
    conf.template,
    conf.css,
    conf.framework,
    conf.auto_install,
    conf.template_root,
    conf.version,
    conf.date,
    conf.compiler,
    conf.period,
  );
  let mut thread_safe_functions = HashMap::new();
  for (key, callback) in handlers {
    thread_safe_functions.insert(key, callback);
  }
  if let Err(e) = project.create(thread_safe_functions).await {
    println!("创建项目错误,原因如下:");
    println!("{:?}", e);
    return Err(napi::Error::from_reason(format!("{:?}", e)));
  }
  Ok(())
}

我们重点来看一下 project.create 函数:

8.1 create 创建文件

// crates/taro_init/src/project.rs
pub async fn create(
    &self,
    js_handlers: HashMap<String, ThreadsafeFunction<CreateOptions>>,
  ) -> anyhow::Result<()> {
    // 省略若干代码
    let all_files = get_all_files_in_folder(template_path.clone(), filter, None)?;
    let mut create_options = CreateOptions {
      // 省略若干代码
    };
    let all_files = all_files.iter().filter_map(|f| f.to_str()).collect::<Vec<_>>();
    println!();
    println!(
      "{} {}",
      style("✔").green(),
      format!(
        "{}{}",
        style("创建项目: ").color256(238),
        style(self.project_name.as_str()).color256(238).bold()
      )
    );
    creator
      .create_files(
        all_files.as_slice(),
        template_path.as_str(),
        &mut create_options,
        &js_handlers,
      )
      .await?;
    // 当选择 rn 模板时,替换默认项目名
    if self.template.eq("react-native") {
      change_default_name_in_template(
        &self.project_name,
        template_path.as_str(),
        project_path_str.as_str(),
      )
      .await?;
    }
    println!();
    init_git(&self.project_name, project_path_str.as_str())?;
    let auto_install = self.auto_install.unwrap_or(true);
    if auto_install {
      install_deps(&self.npm, || self.call_success()).await?;
    } else {
      self.call_success();
    }
    Ok(())
  }

create 主要做了以下几件事情:

  1. 创建项目目录
  2. 创建项目文件 creator.create_files
  3. 初始化 git init_git
  4. 安装依赖 install_deps

如下图所示:

初始化2,创建项目

接着我们重点来看一下 creator.create_files 函数:

8.2 creator.create\_files

// crates/taro_init/src/creator.rs
pub async fn create_files(
    &self,
    files: &[&str],
    template_path: &str,
    options: &mut CreateOptions,
    js_handlers: &HashMap<String, ThreadsafeFunction<CreateOptions>>,
  ) -> anyhow::Result<()> {
    let current_style_ext = STYLE_EXT_MAP
      .get(&options.css.unwrap_or(CSSType::None))
      .unwrap_or(&"css");
    options.css_ext = Some(current_style_ext.to_string());
    for file in files {
      // 省略若干代码...
      if need_create_file {
        // 省略若干代码...
        let dest_path = self.get_destination_path(&[&dest_re_path]);
        let from_path: String = PathBuf::from(file_relative_path)
          .to_string_lossy()
          .to_string();
        self
          .tempate(from_path.as_str(), dest_path.as_str(), &options.clone())
          .await?;
        println!(
          "{} {}",
          style("✔").green(),
          style("创建文件: ".to_owned() + dest_path.as_str()).color256(238)
        );
      }
    }
    Ok(())
  }

我们重点来看一下 creator.tempate 函数:

8.3 creator.tempate 模板

// crates/taro_init/src/creator.rs

pub async fn tempate(
    &self,
    from_path: &str,
    dest_path: &str,
    options: &CreateOptions,
  ) -> anyhow::Result<()> {
    if MEDIA_REGEX.is_match(from_path) {
      let dir_name = PathBuf::from(dest_path)
        .parent()
        .unwrap()
        .to_string_lossy()
        .to_string();
      async_fs::create_dir_all(&dir_name)
        .await
        .with_context(|| format!("文件夹创建失败: {}", dir_name))?;
      async_fs::copy(from_path, dest_path)
        .await
        .with_context(|| format!("文件复制失败: {}", from_path))?;
      return Ok(());
    }
    generate_with_template(from_path, dest_path, options).await?;
    Ok(())
  }

我们重点来看一下 generate_with_template 函数:

8.4 generate\_with\_template 根据数据渲染模板,生成文件

// crates/taro_init/src/utils.rs
pub async fn generate_with_template(from_path: &str, dest_path: &str, data: &impl serde::Serialize) -> anyhow::Result<()> {
  let form_template = async_fs::read(from_path).await.with_context(|| format!("文件读取失败: {}", from_path))?;
  let from_template = String::from_utf8_lossy(&form_template);
  let template = if from_template == "" {
    "".to_string()
  } else {
    HANDLEBARS.render_template(&from_template, data).with_context(|| format!("模板渲染失败: {}", from_path))?
  };
  let dir_name = Path::new(dest_path).parent().unwrap().to_string_lossy().to_string();
  async_fs::create_dir_all(&dir_name).await.with_context(|| format!("文件夹创建失败: {}", dir_name))?;
  let metadata = async_fs::metadata(from_path).await.with_context(|| format!("文件读取失败: {}", from_path))?;
  async_fs::write(dest_path, template).await.with_context(|| format!("文件写入失败: {}", dest_path))?;
  #[cfg(unix)]
  async_fs::set_permissions(dest_path, metadata.permissions()).await.with_context(|| format!("文件权限设置失败: {}", dest_path))?;
  Ok(())
}

taro init 的 rust代码中,安装依赖引入了crates/handlebars rust包,类似 npm 包管理官网

经过 HANDLEBARS.render_template(&from_template, data) handlebars-rust 根据数据渲染模板,生成文件。

比如:handlebars 模板中的 app.config.js => app.config.ts

如下图所示:

handlebars-render.png

更多 handlebars 用法,参考handlebars官网

9. 总结

我们再来看下开头初始化项目的 gif 回顾下整个 taro init 过程:

taro-init-gif-high.gif

根据前面两篇 1. taro cli init2. taro 插件机制 文章,我们可以得知:taro init 初始化命令,最终调用的是 packages/taro-cli/src/presets/commands/init.ts 文件中的 ctx.registerCommand 注册的 init 命令行的 fn 函数。

可以根据配置 .vscode/launch.json 文件调试 taro init node 部分代码和 rust 配置 type:lldb 代码。

export default (ctx: IPluginContext) => {
  ctx.registerCommand({
    name: 'init',
    optionsMap: {
        // 省略若干代码...
    },
    async fn (opts) {
      const Project = require('../../create/project').default
      const project = new Project({
        // 省略若干参数...
      })
      project.create()
    }
  })
}
// packages/taro-cli/src/create/project.ts
async create () {
    try {
        const answers = await this.ask()
        const date = new Date()
        this.conf = Object.assign(this.conf, answers)
        this.conf.date = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
        this.write()
    } catch (error) {
        console.log(chalk.red('创建项目失败: ', error))
    }
}

ask 命令行交互式选择使用的是 inquirer inquirer.prompt 实现。使用 download-git-repo 包(如果是 url 则用 axios 下载)把远程仓库下载到本地移动到packages/taro-cli/templates

import { createProject } from '@tarojs/binding'
// packages/taro-cli/src/create/project.ts
write (cb?: () => void) {
    createProject({
    }, handler).then(() => {
      cb && cb()
    })
}

write 函数中的 createProject 创建文件部分是使用 rust 实现的。使用 napi-rs 包绑定 rust 代码,给 nodejs 调用。

模板部分使用的是 handlebarsrust 使用的 handlebars rust 包 crates/handlebars rust 实现。

根据数据渲染 handlebars 模板,创建项目,生成文件。

再根据包管理器安装依赖。最后打印创建项目成功,请进入项目目录工作。

整个 taro init 创建新项目流程用一张图表示如图所示:

taro init 创建新项目流程


如果看完有收获,欢迎点赞、评论、分享、收藏支持。你的支持和肯定,是我写作的动力。也欢迎提建议和交流讨论

作者:常以若川为名混迹于江湖。所知甚少,唯善学。若川的博客github blog,可以点个 star 鼓励下持续创作。

最后可以持续关注我@若川,欢迎关注我的公众号:若川视野。我倾力持续组织了 3 年多每周大家一起学习 200 行左右的源码共读活动,感兴趣的可以点此扫码加我微信 ruochuan02 参与。另外,想学源码,极力推荐关注我写的专栏《学习源码整体架构系列》,目前是掘金关注人数(6k+人)第一的专栏,写有几十篇源码文章。


若川
7k 声望3.2k 粉丝

你好,我是若川。写有 《学习源码整体架构系列》 几十篇。