学习create-vite,从零到一实现一个自己的 mini-create
0. 前言
本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。
本篇是源码共读第37期 | vite 3.0 都发布了,这次来手撕 create-vite 源码,点击了解本期详情
1. npm create vite 执行原理
我们通过 npm create vite
来创建一个 vite 的项目,那么这一行命令到底背后是如何执行的呢?
通过 npm 官方文档 我们可以知道,npm create vite
其中 create
是 init
的别名,也就是说等于执行了 npm init vite
。平常我们初始化一个 package.json
的时候,会用到 npm init
,通过交互式生成一个 package.json 文件,后面跟了一个 vite
参数后,变成通过 npm-exec
去安装 create-vite
这个包,然后执行其 bin
对应的执行文件。
总结,需要两点
- 包名以
create
开头 - 有
bin
执行脚本
2. 如何 debug
看 README.md
和 CONTRIBUTING.md
,主要是 CONTRIBUTING.md
文件,README.md
是对工具的介绍,CONTRIBUTING.md
是给贡献者看的,所以就会有介绍环境如何搭建。
- Run
pnpm i
in Vite's root folder.- Run
pnpm run build
in Vite's root folder.
在根目录执行以一下两步
pnpm i
安装依赖包pnpm run build
打包
执行完以上两步之后,我们在 packages/create-vite/
下就可以看到一个dist文件。
前面说到执行 npm create vite
其实是执行 create-vite
这个包的 bin
执行文件。
我们可以看一下 packages/create-vite/package.json
{
"name": "create-vite",
"version": "4.3.2",
"type": "module",
"license": "MIT",
"author": "Evan You",
"bin": {
"create-vite": "index.js", // bin 执行文件
"cva": "index.js"
},
...
}
bin.create-vite
指向了 index.js
index.js
#!/usr/bin/env node
import './dist/index.mjs'
导入了 ./dist/index.mjs
,是打包后的文件,被压缩过。
在 CONTRIBUTING.md
的Debugging下介绍了怎么做
在 create-vite
下执行 pnpm run dev
我们再来看 dist/index.mjs
import jiti from "file:///Users/liuyonggui/Documents/source/vite/node_modules/.pnpm/jiti@1.18.2/node_modules/jiti/lib/index.js";
/** @type {import("/Users/liuyonggui/Documents/source/vite/packages/create-vite/src/index")} */
const _module = jiti(null, { interopDefault: true, esmResolve: true })("/Users/liuyonggui/Documents/source/vite/packages/create-vite/src/index.ts");
export default _module;
通过 jiti
这个包,指向了 /src/index.ts
文件
jiti: Runtime Typescript and ESM support for Node.js
接下来就可以在 src/index.ts
的入口函数 init
函数打断点了。
然后运行在 create-vite
目录下执行 node index.js
,就可以进入断点了。
3.源码分析
3.1 用户交互前
源码
...
const argTargetDir = formatTargetDir(argv._[0]);
const argTemplate = argv.template || argv.t;
let targetDir = argTargetDir || defaultTargetDir;
const getProjectName = () =>
targetDir === '.' ? _nodePath.default.basename(_nodePath.default.resolve()) : targetDir;
都是变量的定义,从变量名称可以容易的知道
- argTargetDir: 命令行参数里的目标文件夹
- argTemplate: 命令行参数里的模板
- targetDir: 最终的目标文件夹
- getProjectName: 获取项目名称的函数
argv 是怎么来的?用到了 minimist
这个包
const argv = minimist<{
t?: string
template?: string
}>(process.argv.slice(2), { string: ['_'] })
3.2 用户交互结果收集
源码
try {
result = await prompts(
[
{
type: argTargetDir ? null : 'text',
name: 'projectName',
message: reset('Project name:'),
initial: defaultTargetDir,
onState: (state) => {
targetDir = formatTargetDir(state.value) || defaultTargetDir
},
},
{
type: () =>
!fs.existsSync(targetDir) || isEmpty(targetDir) ? null : 'confirm',
name: 'overwrite',
message: () =>
(targetDir === '.'
? 'Current directory'
: `Target directory "${targetDir}"`) +
` is not empty. Remove existing files and continue?`,
},
{
type: (_, { overwrite }: { overwrite?: boolean }) => {
if (overwrite === false) {
throw new Error(red('✖') + ' Operation cancelled')
}
return null
},
name: 'overwriteChecker',
},
{
type: () => (isValidPackageName(getProjectName()) ? null : 'text'),
name: 'packageName',
message: reset('Package name:'),
initial: () => toValidPackageName(getProjectName()),
validate: (dir) =>
isValidPackageName(dir) || 'Invalid package.json name',
},
{
type:
argTemplate && TEMPLATES.includes(argTemplate) ? null : 'select',
name: 'framework',
message:
typeof argTemplate === 'string' && !TEMPLATES.includes(argTemplate)
? reset(
`"${argTemplate}" isn't a valid template. Please choose from below: `,
)
: reset('Select a framework:'),
initial: 0,
choices: FRAMEWORKS.map((framework) => {
const frameworkColor = framework.color
return {
title: frameworkColor(framework.display || framework.name),
value: framework,
}
}),
},
{
type: (framework: Framework) =>
framework && framework.variants ? 'select' : null,
name: 'variant',
message: reset('Select a variant:'),
choices: (framework: Framework) =>
framework.variants.map((variant) => {
const variantColor = variant.color
return {
title: variantColor(variant.display || variant.name),
value: variant.name,
}
}),
},
],
{
onCancel: () => {
throw new Error(red('✖') + ' Operation cancelled')
},
},
)
} catch (cancelled: any) {
console.log(cancelled.message)
return
}
// user choice associated with prompts
const { framework, overwrite, packageName, variant } = result
这里用到了 prompts
这个包,是用来做命令行交互,收集用户选择信息的。
我们可以看最后收集到的是 framework
, overwrite
, packageName
, variant
这个几个值。
- overwrite:确定要不要覆盖项目最后生成的目录
- packageName:
package.json
文件中的name
值 - framework和variant:通过这两个值确定最后的模板
3.3 目标文件夹处理
const root = path.join(cwd, targetDir) // 目标文件夹根目录
if (overwrite) {
emptyDir(root) // 清空目录
} else if (!fs.existsSync(root)) {
fs.mkdirSync(root, { recursive: true }) // 不存在时,创建一个目录,recursive 为 true,表示如果文件夹不存在时,递归创建,比如/a/b a不存在会创建一个a文件夹,a文件夹下创建b文件夹
}
...
function emptyDir(dir: string) {
if (!fs.existsSync(dir)) { // 不存在,则直接返回
return
}
// 遍历文件夹下的每个文件、文件夹
for (const file of fs.readdirSync(dir)) {
if (file === '.git') { // 保留 .git 文件
continue
}
fs.rmSync(path.resolve(dir, file), { recursive: true, force: true }) // 递归删除
}
}
3.4 确定模板
源码
let template: string = variant || framework?.name || argTemplate
let isReactSwc = false
if (template.includes('-swc')) {
isReactSwc = true
template = template.replace('-swc', '')
}
// 通过process.env.npm_config_user_agent 获取包管理器
const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent)
const pkgManager = pkgInfo ? pkgInfo.name : 'npm'
const isYarn1 = pkgManager === 'yarn' && pkgInfo?.version.startsWith('1.')
const { customCommand } =
FRAMEWORKS.flatMap((f) => f.variants).find((v) => v.name === template) ?? {}
if (customCommand) { // 主要是根据不同的包管理器,有部分模板需要执行特殊的命令
const fullCustomCommand = customCommand
.replace(/^npm create /, () => {
// `bun create` uses it's own set of templates,
// the closest alternative is using `bun x` directly on the package
if (pkgManager === 'bun') {
return 'bun x create-'
}
return `${pkgManager} create `
})
// Only Yarn 1.x doesn't support `@version` in the `create` command
.replace('@latest', () => (isYarn1 ? '' : '@latest'))
.replace(/^npm exec/, () => {
// Prefer `pnpm dlx`, `yarn dlx`, or `bun x`
if (pkgManager === 'pnpm') {
return 'pnpm dlx'
}
if (pkgManager === 'yarn' && !isYarn1) {
return 'yarn dlx'
}
if (pkgManager === 'bun') {
return 'bun x'
}
// Use `npm exec` in all other cases,
// including Yarn 1.x and other custom npm clients.
return 'npm exec'
})
const [command, ...args] = fullCustomCommand.split(' ')
// we replace TARGET_DIR here because targetDir may include a space
const replacedArgs = args.map((arg) => arg.replace('TARGET_DIR', targetDir))
const { status } = spawn.sync(command, replacedArgs, {
stdio: 'inherit',
})
process.exit(status ?? 0)
}
3.5 下载模板
源码
// 确定模板的地址
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)
}
}
const files = fs.readdirSync(templateDir)
// 遍历模板文件,进行写入
for (const file of files.filter((f) => f !== 'package.json')) {
write(file)
}
// 获取 packageName,写入修改name后的内容
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) + '\n')
// 如果有swc的,替换一下插件
if (isReactSwc) {
setupReactSwc(root, template.endsWith('-ts'))
}
上面用到的方法
function copy(src: string, dest: string) {
const stat = fs.statSync(src)
if (stat.isDirectory()) {
copyDir(src, dest)
} else {
fs.copyFileSync(src, dest)
}
}
function copyDir(srcDir: string, destDir: string) {
fs.mkdirSync(destDir, { recursive: true })
for (const file of fs.readdirSync(srcDir)) {
const srcFile = path.resolve(srcDir, file)
const destFile = path.resolve(destDir, file)
copy(srcFile, destFile)
}
}
function setupReactSwc(root: string, isTs: boolean) {
// 安装包替换
editFile(path.resolve(root, 'package.json'), (content) => {
return content.replace(
/"@vitejs\/plugin-react": ".+?"/,
`"@vitejs/plugin-react-swc": "^3.3.2"`,
)
})
// 插件替换
editFile(
path.resolve(root, `vite.config.${isTs ? 'ts' : 'js'}`),
(content) => {
return content.replace('@vitejs/plugin-react', '@vitejs/plugin-react-swc')
},
)
}
function editFile(file: string, callback: (content: string) => string) {
const content = fs.readFileSync(file, 'utf-8')
fs.writeFileSync(file, callback(content), 'utf-8')
}
3.6 最后输出一下打印信息
源码
const cdProjectName = path.relative(cwd, root)
console.log(`\nDone. Now run:\n`)
if (root !== cwd) {
console.log(
` cd ${
cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName
}`,
)
}
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()
4. 自己实现一个mini版本的 create-cli
通过上面的源码分析,我们知道要实现这样一个create-cli,最主要的步骤为
- 解析命令行参数 (minimist)
- 用户选择(可选) (prompts)
- 找到对应的模板拷贝到指定的目录(fs-extra)
4.1 创建文件目录
创建一个文件夹,执行 pnpm init
,修改package.json中的 name
为 create-ab
{
"name": "create-ab", // 包名
"version": "1.0.0",
"description": "",
"main": "index.js",
"bin": {
"create-ab": "./index.js" // bin执行文件
},
...
}
然后创建一个 index.js
#!/usr/bin/env node
console.log('this is a mini create cli');
最终目录
.
├── README.md # 写一些这个包的描述
├── index.js
└── package.json
这样一个最简陋的架子就搭建好了,可以发布个npm包试试效果。
# 因为 默认的仓库源用的是淘宝源,所以加个发布的仓库为官方源
npm publish --registry=https://registry.npmjs.org
发布成功后,就可以执行 pnpm create ab
4.2 收集命令行参数
主要要收集2个信息,一个是目标目录,一个是选择的模板,我们暂时都通过命令行参数中获取。
这里使用跟 create-vite
一样的包,minimist
先安装依赖包
pnpm i minimist
编写 index.js
文件
#!/usr/bin/env node
const minimist = require('minimist');
const argv = minimist(process.argv.slice(2));
// 入口函数
function init() {
console.log(argv);
}
// 执行
init();
最终执行一下, 我们用 template
表示选择的模板的名字,name
表示项目的名字
node index.js --template a --name my-app
![[Pasted image 20230709062422.png]]
4.3 开始下载模板
拿到模板的名字和项目的名字,我们就可以开始下载模板了。 现在项目中创建两个示例模板
.
├── README.md
├── index.js
├── package.json
├── template-a
│ ├── index.js
│ ├── package.json
│ └── public
│ └── config.js
└── template-b
├── index.js
├── package.json
└── public
└── config.js
模板里面有一级目录,也有二级目录
拷贝文件,在 create-vite
中是自己实现的,我们这里可以借助 第三方包 fs-extra
,它是对 fs
模块的扩展,比如我们这里要用到的递归拷贝整个文件夹,原生的fs模块是没有的,只能拷贝文件。
安装依赖包
pnpm i fs-extra
编写逻辑
#!/usr/bin/env node
const minimist = require('minimist');
const fse = require('fs-extra');
const path = require('path');
const argv = minimist(process.argv.slice(2));
function init() {
const { template, name } = argv;
const targetDir = path.resolve(process.cwd(), name); // 目标文件夹,就是当前目录 + 项目的名字
const templateDir = path.resolve(__dirname, `template-${template}`); // 模板所在的路径
fse.copySync(templateDir, targetDir); // 拷贝整个模板目录
}
init();
这样再执行上面的命令
node index.js --template a --name my-app
就可以看到当前目录下多了一个 my-app
![[Pasted image 20230709064257.png]]
为了更友好一些,需要将 package.json 的name也要修改一下。
let pkg = JSON.parse(fse.readFileSync(path.resolve(targetDir, 'package.json'), 'utf-8'));
pkg.name = name;
fse.writeFileSync(path.resolve(targetDir, 'package.json'), JSON.stringify(pkg, null, 2));
这样最终生成的 package.json 中的 name 就是 my-app
4.4 用交互式的方式,让用户选择模板
这里需要用到 prompts 这个包,
pnpm i prompts
然后编写提示的逻辑,这里需要等待用户的输入完成,所以需要将init函数变为异步函数
async function init() {
const { template, name } = argv;
const result = await prompts([
{
type: name ? null : 'text',
name: 'name',
message: 'please input your project name',
},
{
type: template ? null : 'select',
name: 'template',
message: 'please select a template',
choices: [
{
title: 'templateA',
value: 'a',
},
{
title: 'templateB',
value: 'b',
},
],
},
]);
const templateName = template || result.template;
const projectName = name || result.name;
const targetDir = path.resolve(process.cwd(), projectName);
const templateDir = path.resolve(__dirname, `template-${templateName}`);
fse.copySync(templateDir, targetDir);
let pkg = JSON.parse(fse.readFileSync(path.resolve(targetDir, 'package.json'), 'utf-8'));
pkg.name = projectName;
fse.writeFileSync(path.resolve(targetDir, 'package.json'), JSON.stringify(pkg, null, 2));
}
type为null时,则不用进行选择,用户手动输入的优先级更高。
至此,整个代码实现就完成了。附上一下 源码链接
最后发布一下
npm publish --registry=https://registry.npmjs.org
这样就要可以使用了
pnpm create ab
5 总结
知道了整个实现的思路和原理之后,就会发现 create-vite
的实现原理其实很简单。
用到了几个第三方包 minimist
、 prompts
。
mini-create
部分没有实现对包管理的处理、 create-vite
的 ts
部分,以及用 kolorist
对选项加色等功能,但整体的思路是有了。这样以后自己要实现一个自己的 create-xxx
就不慌了。
最后说一下,平常不写文章,发现写文章比理解原理还难,还是要多写才行。
转载自:https://juejin.cn/post/7253389022939136061