赞
踩
本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。
这是源码共读的第8期,链接:【若川视野 x 源码共读】第37期 | vite 3.0 都发布了,这次来手撕 create-vite 源码。
ts
源码tsconfig.json
中的 esmoduleInterop
的作用prompts
的基本用法create-vite
脚手架的流程、源码解析当使用 node
执行后缀名为 .ts
的文件时,通常你将会在终端得到 Unknown file extension ".ts" for XXX
的错误。所以这个时候我们要比平时直接上来调试js文件多一个步骤。整体的思路很简单,要不就将我们要调整的 ts
文件转换成 js
文件;要不就找一个能执行 ts
的和 node
功能相同的执行环境。下面介绍两种调试 .ts
源码文件的方式。
使用 ts-node
会将 .ts
文件进行“编译”+“执行”操作,通常情况下可以直接使用它来进行 .ts
文件进行调试,但是如果你要调试的代码是 ES Module
(package.json
中声明了 "type": "module"
),使用这种方法依然会有上文得到的错误,这个时候我们可以使用 ts-node-esm
命令来进行调试。本文阅读的源码 create-vite
就是 ES Module
所以在调试的时候请注意!
安装
ts-node
后可以直接使用ts-node-esm
命令
既然是使用 TypeScript 写的代码,最直接的一个想法,就是利用 tsc
将代码进行编译和转换,然后在利用 node
来进行代码的调试。这里同样要注意一点如果你调试的代码中引入了 commonjs
规范的其他包模块,但是代码中引入的方式依然是 ES Module
规范的话。还需要在 vue.config.js
中添加如下配置:
"compilerOptions": {
"esModuleInterop": true
}
这个问题解决之后,就可以利用vscode中的调试功能进行调试了。
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
的值的。
prompts
的基本用法prompts
是一个通过提问方式用来收集用户需求的包模块,使用起来简单方便,在 create-vite
中使用了它。所以这里做一下简单的介绍,方便之后的源码阅读。
prompts
是一个对象数组,包含所有配置项,详细后文会提到。options
是一个对象,其中可以配置两个方法 onCancel
、onSubmit
onCancel:在用户取消(ctrl+c等类似操作)时会被调用,有两个参数
prompt
(当前提示内容)、answers
(用户的所有选择)
onSubmit:用户提交(按下Enter、return进行下一条)时调用,有三个参数prompt
(当前提示内容)、answer
(用户针对当前提示的选择)、answers
(用户的所有选择)
[{ // 提示的类型,如果是一个假值,跳过当前的提示,可选值如下 // 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
中也是大量的用了函数类型来确保流程的灵活性。
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
文件。至此,我们算是可以正式开始源码的流程和解读了。给上文提到的脚手架模型一个较深刻的印象,在解读的时候也根据模型的大体结构来分步骤解读:用户输入信息收集
,对用户输入的信息进行解析
,项目生成
。
// 引入相关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
的时候进行的用户信息收集,也是脚手架的第一步。开始命令脚手架开始工作。
按照代码顺序继续走可以找到一个定义的 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 } ] }, ... ]
// 根据上文的变量提取出一个name组成的字符串数组
const TEMPLATES = FRAMEWORKS.map(
(f) => (f.variants && f.variants.map((v) => v.name)) || [f.name]
).reduce((a, b) => a.concat(b), [])
// 默认生成的项目名称
const defaultTargetDir =
进入主要的 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
流程的代码比较多,所以不进行逐句注释,先进行代码总结后梳理一下流程图更便于理解,然后会选用部分特殊代码进行解读。
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'
},
...
]
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 })
}
这部分主要是为了某些用户并不想使用提供的模板,所以也给用户了自定义的选项提供。比如当用户选择的模板是 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) }
如果没有使用自定义的模板,那么就可以更方便的使用官方提供的模板。下面的代码主要做的就是使用 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()
通读完代码,希望在我的叠叠不休下,可以帮助你了解或者掌握:
ts-node-esc命令,或者进行编译,同时注意包模块的规范,不同规范引用的时候,记得使用esmoduleInterop: true配置。
一款极简单的模块,来帮助你快速完成脚手架,同时搭配
commander
包,让你的脚手架功能配置型更强
一定要始终记得脚手架的基本结构,围绕着结构从大到小进行解读或者自己脚手架的搭建。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。