从create-vite源码看前端脚手架

前言

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

这是源码共读的第8期,链接:【若川视野 x 源码共读】第37期 | vite 3.0 都发布了,这次来手撕 create-vite 源码

目录

  1. 如何调试 ts 源码
  2. tsconfig.json 中的 esmoduleInterop 的作用
  3. prompts 的基本用法
  4. create-vite 脚手架的流程、源码解析
  5. 总结
  6. 参考文献

1. 如何调试ts源码

当使用 node 执行后缀名为 .ts 的文件时,通常你将会在终端得到 Unknown file extension ".ts" for XXX 的错误。所以这个时候我们要比平时直接上来调试js文件多一个步骤。整体的思路很简单,要不就将我们要调整的 ts 文件转换成 js 文件;要不就找一个能执行 ts 的和 node 功能相同的执行环境。下面介绍两种调试 .ts 源码文件的方式。

1.1 ts-node

使用 ts-node 会将 .ts 文件进行“编译”+“执行”操作,通常情况下可以直接使用它来进行 .ts 文件进行调试,但是如果你要调试的代码是 ES Module (package.json 中声明了 "type": "module" ),使用这种方法依然会有上文得到的错误,这个时候我们可以使用 ts-node-esm 命令来进行调试。本文阅读的源码 create-vite 就是 ES Module 所以在调试的时候请注意!

安装 ts-node 后可以直接使用 ts-node-esm 命令

1.2 tsc进行代码编译成为js后进行调试

既然是使用 TypeScript 写的代码,最直接的一个想法,就是利用 tsc 将代码进行编译和转换,然后在利用 node 来进行代码的调试。这里同样要注意一点如果你调试的代码中引入了 commonjs 规范的其他包模块,但是代码中引入的方式依然是 ES Module 规范的话。还需要在 vue.config.js 中添加如下配置:

"compilerOptions": {
    "esModuleInterop": true
 }

这个问题解决之后,就可以利用vscode中的调试功能进行调试了。

  1. 打开调试终端
    ![在这里插入图片描述](https://img-blog.csdnimg.cn/8de6d77bf4744e20b1d55e6a56c6e132.png
  2. 开始源码调试之旅
    在这里插入图片描述

2.tsconfig.json 中的 esmoduleInterop 的作用

这部分主要解释一下 esModuleInterop 的作用,这个问题是如何发现的,当我在调试源码的时候,想要直接使用 tsc 编译单一一个文件,并且没有指定相关的配置文件,终端提示了如下错误 Module '"node:fs"' has no default export. 。错误信息也很好懂,就是说模块 node:fs 并没有默认的导出。它对应在 create-vite 的文件中代码是:

import fs from 'node:fs'
console.log(fs)

很明显 node:fs 模块是 commonJs 规范所写的,通过 import from 这种方式是找不到的默认导出的 default 的,因为本来 commonJs 规范也没有 default 一说。而且在ESmodule模块中,我们又无法直接使用require来进行包模块引用,所以这个时候我们使用 tsc 去编译的时候。会生成如下的代码。

"use strict";
exports.__esModule = true;
var node_fs_1 = require("node:fs");
// undefined
console.log(node_fs_1["default"]);

这样的代码运行后的输出是 undefined。所以 esmoduleInterop 的作用就是为了解决这个问题,该配置的默认值是false,当我们设置为true后,再打包之后的文件如下:

"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
exports.__esModule = true;
var node_fs_1 = __importDefault(require("node:fs"));
console.log(node_fs_1["default"]);

可以看到它使用了一个函数来做了中间代理,默认添加了一个 default ,所以执行当前的代码是可以正常拿到 fs 的值的。

3.prompts 的基本用法

prompts 是一个通过提问方式用来收集用户需求的包模块,使用起来简单方便,在 create-vite 中使用了它。所以这里做一下简单的介绍,方便之后的源码阅读。

  • api方法
  1. prompts(prompts, options):主要的方法,用来展示提示和收集用户的选择。
    prompts 是一个对象数组,包含所有配置项,详细后文会提到。
    options是一个对象,其中可以配置两个方法 onCancelonSubmit

onCancel:在用户取消(ctrl+c等类似操作)时会被调用,有两个参数prompt(当前提示内容)、answers(用户的所有选择)
onSubmit:用户提交(按下Enter、return进行下一条)时调用,有三个参数prompt(当前提示内容)、answer(用户针对当前提示的选择)、answers(用户的所有选择)

  1. override:看起来像是将其他相同功能的包模块内容集成进来。
  2. inject:模拟用户的输入来测试当前的流程,官网也指出该方法多用于功能测试。
  • prompts
    上文说过它的类型是对象数组,用来进行配置提示的内容和流程。
[{
  // 提示的类型,如果是一个假值,跳过当前的提示,可选值如下	
  // text、password、invisible、number、confirm、list、toggle、select、
  // multiselect、autocompleteMultiselect、autocomplete、date、假值
  type: String | Function,
  // 用户的答案会被存储在一个对象中,该属性为对象中的键值
  name: String | Function,
  // 展示在终端提示的信息
  message: String | Function,
  // 初始化的值
  initial: String | Function | Async Function,
  // 用户输入的答案进行格式化
  format: Function | Async Function,
  // 在提示渲染额时候调用
  onRender: Function,
  // 在用户进行操作(选择、输入等)调用
  onState: Function,
  // 输入流,默认是process.stdin
  stdin: Readable,
  // 输出流,默认是process.stdout
  stdout: Writeable
}]

最简单的一个demo如下:

const prompts = require('prompts');
const options = [
    {   
        type: 'text',
        name: 'name',   
        message: 'what is your name?'
    },
    {
        type: 'text',
        name: 'age',
        message: 'how old are you?'
    }
];

(async () => {
    // 返回一个promise
    const response = await prompts(options, {
        // 在用户进行了提交的时候,通过onSubmit抓取当前的提示内容prompts,用户当前选择answer,用户全部的选择answers
        onSubmit: (prompts, answer, answers) => {
            console.log(prompts);
            console.log(answer);
            console.log(answers);
        },
        // 用户取消当前流程的时候调用的函数
        // onCancel: (prompt, answers) => {
        //    console.log(prompt);
        //    console.log(answers);
        // }
    });
    console.log(response );		// { name: 'hello', age: '33' }
})();

在使用的时候尤其要注意可以用函数来进行配置的属性,灵活度更高,在 create-vite 中也是大量的用了函数类型来确保流程的灵活性。

4. create-vite 脚手架的流程、源码解析

这一模块将进入重点,关于 create-vite 的源码解析。
所谓的脚手架其实就是当公司业务项目繁多但是底层的架构大体相同的时候,为了减少我们重复搭建项目底层架构的时间而顺势生成的一种敏捷开发手段。通过终端输入几条简单的命令,快速的生成底层架构类似的项目,它可以涵盖所有业务之外你所需要的内容,比如:框架、代码检测、包管理工具、mock数据等。脚手架的流程都类似:
在这里插入图片描述
先将vite的代码下载下来:github地址:https://github.com/vitejs/vite
关于 create-vite 的代码在目录 packages/create-vite 中。照例想分析一个npm包模块先看它的“身份证” package.json ,找到命令指向的文件。
在这里插入图片描述

跟随 bin 属性下的内容,进入文件

在这里插入图片描述

看到这里不要慌,这证明它引入的是一个打包好的模块,根据 package.json 中的打包命令,可以看出使用了 unbuild 工具,在目录可以查找到相关的配置文件 build.config.ts

unbuild 是一个统一的构建系统。

在这里插入图片描述
根据入口文件找出 src 下的 index 文件。至此,我们算是可以正式开始源码的流程和解读了。给上文提到的脚手架模型一个较深刻的印象,在解读的时候也根据模型的大体结构来分步骤解读:用户输入信息收集 对用户输入的信息进行解析项目生成

4.1用户输入信息收集

// 引入相关node包模块
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// 可以开启另外的node进程,这里生成的进程用作最终项目的生成
import spawn from 'cross-spawn'
// 一款轻量级的命令行参数解析的引擎工具包
import minimist from 'minimist'
// 上文提到过的主要用来处理用户和终端信息交互的提示工具包
import prompts from 'prompts'
// 给提示信息进行色彩渲染的工具包
import {
  blue,
  cyan,
  green,
  lightGreen,
  lightRed,
  magenta,
  red,
  reset,
  yellow
} from 'kolorist'

// 处理收集到的用户输入的信息
const argv = minimist<{
  t?: string
  template?: string
}>(process.argv.slice(2), { string: ['_'] })
// 当前的工作目录
const cwd = process.cwd()

上述的看似简单的代码实际上就完成了第一步,当我们根据 vite 官方文档进行 pnpm create vite 的时候进行的用户信息收集,也是脚手架的第一步。开始命令脚手架开始工作。

4.2 配置和用户交流的prompts

按照代码顺序继续走可以找到一个定义的 FRAMEWORKS 变量,类型也是一个对象数组。将即将来到的 prompts 的配置进行了加工定制,不得不说仔细思考的话,这一步能让我们定义的变量更加集中,不光方便查找,还能满足整体流程的使用,使数据流看起来异常的清晰,下面代码是定义中的部分。

const FRAMEWORKS: Framework[] = [
  {
    name: 'vanilla',
    display: 'Vanilla',
    color: yellow,
    variants: [
      {
        name: 'vanilla',
        display: 'JavaScript',
        color: yellow
      },
      {
        name: 'vanilla-ts',
        display: 'TypeScript',
        color: blue
      }
    ]
  },
  ...
]
4.2.1 整理出template模板内容
// 根据上文的变量提取出一个name组成的字符串数组
const TEMPLATES = FRAMEWORKS.map(
  (f) => (f.variants && f.variants.map((v) => v.name)) || [f.name]
).reduce((a, b) => a.concat(b), [])
// 默认生成的项目名称
const defaultTargetDir = 
4.2.2 定义一些流程中使用的变量

进入主要的 init 函数,由于内容过长,我们也进行分步来解读一下。

  // formatTargetDir用来将输入的准备生成的项目名称进行格式化,去掉最末尾的"/"
  const argTargetDir = formatTargetDir(argv._[0])
  
  // 提取模板名称,比如 pnpm create myproject --template|--t vue中则该变量就是vue
  // 需要注意如果调试的时候你的npm版本高于7,需要额外写两个--
  //  pnpm create myproject -- --template|-- --t vue
  const argTemplate = argv.template || argv.t
  
  // 目标项目路径,默认为'vite-project'
  let targetDir = argTargetDir || defaultTargetDir
  
  // 一个获取项目名称的方法
  const getProjectName = () =>
    targetDir === '.' ? path.basename(path.resolve()) : targetDir
4.2.3 执行问答流程

流程的代码比较多,所以不进行逐句注释,先进行代码总结后梳理一下流程图更便于理解,然后会选用部分特殊代码进行解读。

init () {
	...
	// 收集用户答案的流程
	// 定义一个接收用户制定的答案
	let result: prompts.Answers<
  		'projectName' | 'overwrite' | 'packageName' | 'framework' | 'variant'
	>
	try {
		// prompts是指一提示的配置内容
		result = await prompts(prompts)
	} catch (cancelled: any) {
		// 打印报错信息
		console.log(cancelled.message)
	}
	// 解析用户的选择:框架、是否重写、项目名、框架变体
    const { framework, overwrite, packageName, variant } = result
	...
}


为了尽可能全的跑完流程,所以我们在解读的时候,不输入任何内容仅仅调用起来脚手架,类似执行 pnpm create vite

在这里插入图片描述
在代码中有一部分如下:

[
...
	{
	  // 这里的"_"是指上一个问题的用户的答案即:是否重写目标文件夹
	  // 这一步提示并不会呈现在终端,只是作为一个数据流程中的完整性存在,在选择不重写的时候抛出错误
	  type: (_, { overwrite }: { overwrite?: boolean }) => {
	    if (overwrite === false) {
	      throw new Error(red('✖') + ' Operation cancelled')
	    }
	    return null
	  },
	  name: 'overwriteChecker'
	},
...
]

4.3 项目的生成

4.3.1 项目文件夹整理
  const { framework, overwrite, packageName, variant } = result
  const root = path.join(cwd, targetDir)
  
  // 如果用户选择重写目标文件夹,则进行文件夹内内容清空
  if (overwrite) {
    emptyDir(root)
  } else if (!fs.existsSync(root)) {
  	// 在文件夹不存在的时候,创建文件夹
    fs.mkdirSync(root, { recursive: true })
  }
4.3.2 自定义模板生成

这部分主要是为了某些用户并不想使用提供的模板,所以也给用户了自定义的选项提供。比如当用户选择的模板是 custom-create-vue 的时候,在前文提到过的 FRAMEWORKS 可以看到如下配置:

{
   name: 'custom-create-vue',
   display: 'Customize with create-vue ↗',
   color: green,
   // 重点是这个属性!!!
   customCommand: 'npm create vue@latest TARGET_DIR'
}

然后会根据这个模板中的 customCommand 来进行一个自定义模板的配置

const { customCommand } =
    FRAMEWORKS.flatMap((f) => f.variants).find((v) => v.name === template) ?? {}
  // 当选择的模板是自定义配置
  if (customCommand) {
    // 将默认的命令进行符合用户环境的命令,如选择用户使用的包模块管理工具
    const fullCustomCommand = customCommand
      .replace('TARGET_DIR', targetDir)
      .replace(/^npm create/, `${pkgManager} create`)
      // Only Yarn 1.x doesn't support `@version` in the `create` command
      .replace('@latest', () => (isYarn1 ? '' : '@latest'))
      .replace(/^npm exec/, () => {
        // Prefer `pnpm dlx` or `yarn dlx`
        if (pkgManager === 'pnpm') {
          return 'pnpm dlx'
        }
        if (pkgManager === 'yarn' && !isYarn1) {
          return 'yarn dlx'
        }
        // Use `npm exec` in all other cases,
        // including Yarn 1.x and other custom npm clients.
        return 'npm exec'
      })

    const [command, ...args] = fullCustomCommand.split(' ')
    // 开启一个进程进行项目的生成
    const { status } = spawn.sync(command, args, {
      stdio: 'inherit'
    })
    process.exit(status ?? 0)
  }

4.3.3 使用官方提供的模板进行项目生成

如果没有使用自定义的模板,那么就可以更方便的使用官方提供的模板。下面的代码主要做的就是使用 fs 来进行文件读取,然后输出到目标文件夹

// 找到用户选择的官方提供的模板
const templateDir = path.resolve(
    fileURLToPath(import.meta.url),
    '../..',
    `template-${template}`
  )

  // 定义一个写文件的方法
  const write = (file: string, content?: string) => {
    const targetPath = path.join(root, renameFiles[file] ?? file)
    if (content) {
      fs.writeFileSync(targetPath, content)
    } else {
      copy(path.join(templateDir, file), targetPath)
    }
  }
  
  // 读取文件,除了package.json,剩下的内容都写入
  const files = fs.readdirSync(templateDir)
  for (const file of files.filter((f) => f !== 'package.json')) {
    write(file)
  }

  // 读取一下模板内容中的package.json
  const pkg = JSON.parse(
    fs.readFileSync(path.join(templateDir, `package.json`), 'utf-8')
  )
  // 给它重新个名字,也就是用户生成的项目名字
  pkg.name = packageName || getProjectName()

  write('package.json', JSON.stringify(pkg, null, 2))
  
  // 有始有终,在项目生成后,继续提醒用户该如何使用,执行哪些命令
  console.log(`\nDone. Now run:\n`)
  if (root !== cwd) {
    console.log(`  cd ${path.relative(cwd, root)}`)
  }
  switch (pkgManager) {
    case 'yarn':
      console.log('  yarn')
      console.log('  yarn dev')
      break
    default:
      console.log(`  ${pkgManager} install`)
      console.log(`  ${pkgManager} run dev`)
      break
  }
  console.log()

5.总结

通读完代码,希望在我的叠叠不休下,可以帮助你了解或者掌握:

  • 如何调试ts源码

ts-node-esc命令,或者进行编译,同时注意包模块的规范,不同规范引用的时候,记得使用esmoduleInterop: true配置。

  • prompts的用法

一款极简单的模块,来帮助你快速完成脚手架,同时搭配 commander 包,让你的脚手架功能配置型更强

  • create-vite的源码解读

一定要始终记得脚手架的基本结构,围绕着结构从大到小进行解读或者自己脚手架的搭建。

6.参考文献

vite官方文档
esModuleInterop 到底做了什么?
npmjs: prompts

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值