- 本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。
- 第37期 | vite 3.0 都发布了,经常初始化 vite 项目,却不知 create-vite 原理?揭秘!
1 学习目标
分析 create-vite 源码
2 初始化项目
当使用npm create vite@latest初始化 vite 项目,其中 create 是 init 的别名、vite@latest 代表的意思是 vite 的最新版本,等于npx create-vite@latest
npx 是 npm 5.2 之后的一个命令,主要用于解决调用项目内部安装的模块
上面执行的命令,npx 会到 node_modules/.bin 和 PATH 中检查 create-vite 是否存在
如果不存在,则会临时下载 create-vite,使用后删除
3 克隆调试项目
vite GitHub 地址,根据项目内的 README.md 和 CONTRIBUTING.md 文档安装调试
git clone https://github.com/vitejs/vite.git
cd vite
// 根据文档说明执行安装
// "preinstall": "npx only-allow pnpm",只允许使用 pnpm
pnpm i
// 当前 vite 版本为 3.1,从 3.1 版本开始,vite 使用 typescript
// 调试需使用 esno 库 (执行 ts 文件)
npx esno packages/create-vite/src/index.ts
控制台完整输出
4 主流程分析
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// 用于开启多进程 https://www.npmjs.com/package/cross-spawn
import spawn from 'cross-spawn'
// 解析命令行参数 https://www.npmjs.com/package/minimist
import minimist from 'minimist'
// 提示选择 https://www.npmjs.com/package/prompts
import prompts from 'prompts'
// 终端色彩库 https://www.npmjs.com/package/kolorist
import {
blue,
cyan,
green,
lightGreen,
lightRed,
magenta,
red,
reset,
yellow
} from 'kolorist'
// node 执行目录
const cwd = process.cwd()
/*
解析 npx create-vite@latest projectName --template/--t vue
agrv = {
_: ['projectName']
template: 'vue'
}
*/
const argv = minimist<{
t?: string
template?: string
}>(process.argv.slice(2), { string: ['_'] })
// 入口函数
async function init() {
...
}
init().catch((e) => {
console.error(e)
})
4.1 获取命令行上指定的项目名称
// 默认输出的项目文件夹名称
const defaultTargetDir = 'vite-project'
// 获取命令行指定的项目文件夹名称
const argTargetDir = formatTargetDir(argv._[0])
// 目标文件夹名称
let targetDir = argTargetDir || defaultTargetDir
// 获取项目名称,如果指定的项目名称为点,则取当前工作目录名称
const getProjectName = () =>
targetDir === '.' ? path.basename(path.resolve()) : targetDir
4.1.1 formatTargetDir 函数
// 格式化为有效的文件夹名称,替换反斜杠为空字符
function formatTargetDir(targetDir: string | undefined) {
return targetDir?.trim().replace(/\/+$/g, '')
}
4.2 获取命令行上指定的框架模板
// 类型定义
type ColorFunc = (str: string | number) => string
type Framework = {
name: string
display: string
color: ColorFunc
variants: FrameworkVariant[]
}
type FrameworkVariant = {
name: string
display: string
color: ColorFunc
customCommand?: string
}
// 模板框架以及框架变体
const FRAMEWORKS: Framework[] = [
{
name: 'vanilla',
display: 'Vanilla',
color: yellow,
variants: [
{
name: 'vanilla',
display: 'JavaScript',
color: yellow
},
{
name: 'vanilla-ts',
display: 'TypeScript',
color: blue
}
]
},
{
name: 'vue',
display: 'Vue',
color: green,
variants: [
{
name: 'vue',
display: 'JavaScript',
color: yellow
},
{
name: 'vue-ts',
display: 'TypeScript',
color: blue
},
{
name: 'custom-create-vue',
display: 'Customize with create-vue ↗',
color: green,
customCommand: 'npm create vue@latest TARGET_DIR'
},
{
name: 'custom-nuxt',
display: 'Nuxt ↗',
color: lightGreen,
customCommand: 'npm exec nuxi init TARGET_DIR'
}
]
},
{
name: 'react',
display: 'React',
color: cyan,
variants: [
{
name: 'react',
display: 'JavaScript',
color: yellow
},
{
name: 'react-ts',
display: 'TypeScript',
color: blue
}
]
},
{
name: 'preact',
display: 'Preact',
color: magenta,
variants: [
{
name: 'preact',
display: 'JavaScript',
color: yellow
},
{
name: 'preact-ts',
display: 'TypeScript',
color: blue
}
]
},
{
name: 'lit',
display: 'Lit',
color: lightRed,
variants: [
{
name: 'lit',
display: 'JavaScript',
color: yellow
},
{
name: 'lit-ts',
display: 'TypeScript',
color: blue
}
]
},
{
name: 'svelte',
display: 'Svelte',
color: red,
variants: [
{
name: 'svelte',
display: 'JavaScript',
color: yellow
},
{
name: 'svelte-ts',
display: 'TypeScript',
color: blue
},
{
name: 'custom-svelte-kit',
display: 'SvelteKit ↗',
color: red,
customCommand: 'npm create svelte@latest TARGET_DIR'
}
]
},
{
name: 'others',
display: 'Others',
color: reset,
variants: [
{
name: 'create-vite-extra',
display: 'create-vite-extra ↗',
color: reset,
customCommand: 'npm create vite-extra@latest TARGET_DIR'
}
]
}
]
// 取出全部框架变体,方便匹配命令行中指定的框架
const TEMPLATES = FRAMEWORKS.map(
(f) => (f.variants && f.variants.map((v) => v.name)) || [f.name]
).reduce((a, b) => a.concat(b), [])
4.3 提示选择框架、项目名称等
let result: prompts.Answers<
'projectName' | 'overwrite' | 'packageName' | 'framework' | 'variant'
>
// 获取命令行中指定的框架模板
const argTemplate = argv.template || argv.t
try {
// 执行 prompts
result = await prompts(
[
{
// 显示的提示类型,如果值为 null 则跳过
type: argTargetDir ? null : 'text',
// 保存 key 值
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?`
},
{
// overwrite 选择为 N 时,抛出 message,选择为 Y 时,跳过
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()),
// 实时检查用户输入的值,应返回 true, 如果返回 false 则显示 'Invalid ...'
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:'),
choices: FRAMEWORKS.map((framework) => {
const frameworkColor = framework.color
return {
title: frameworkColor(framework.display || framework.name),
value: framework
}
})
},
{
// 提示选择框架变体 framework.variants
type: (framework: Framework) => {
return 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
// framework:框架名称 overwrite:是否覆盖已有目录
// packageName:指定的项目名称 variant:选择的框架变体
const { framework, overwrite, packageName, variant } = result
4.3.1 toValidPackageName 函数
// 转换为有效的包名称
function toValidPackageName(projectName: string) {
return projectName
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/^[._]/, '')
.replace(/[^a-z0-9-~]+/g, '-')
}
4.3.2 isValidPackageName 函数
// 有效包名判断
function isValidPackageName(projectName: string) {
return /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(
projectName
)
}
4.3.3 isEmpty 函数
// 是否为空文件夹
function isEmpty(path: string) {
const files = fs.readdirSync(path)
return files.length === 0 || (files.length === 1 && files[0] === '.git')
}
4.4 覆盖已有目录/创建指定项目名称目录
// 指定目录创建位置
const root = path.join(cwd, targetDir)
if (overwrite) {
// 选择 overwrite yes,删除除了.git以外文件,
emptyDir(root)
} else if (!fs.existsSync(root)) {
// 不存在文件夹,进行创建
fs.mkdirSync(root, { recursive: true })
}
4.4.1 emptyDir 函数
// 删除文件夹内容
function emptyDir(dir: string) {
if (!fs.existsSync(dir)) {
return
}
for (const file of fs.readdirSync(dir)) {
if (file === '.git') {
continue
}
// recursive true 递归删除,force true 忽略路径不存在异常
fs.rmSync(path.resolve(dir, file), { recursive: true, force: true })
}
}
4.5 若用户选择自定义创建vue模板,执行命令并退出程序
// determine template
const template: string = variant || framework?.name || argTemplate
// 获取当前用户包管理器
const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent)
const pkgManager = pkgInfo ? pkgInfo.name : 'npm'
const isYarn1 = pkgManager === 'yarn' && pkgInfo?.version.startsWith('1.')
// - ?? 空值合并运算符,当表达式为 null 或 undefined 时,为变量设置一个默认值
// 用户选择 customCommand 自定义创建 vue 命令
const { customCommand } =
FRAMEWORKS.flatMap((f) => f.variants).find((v) => v.name === template) ?? {}
// 执行自定义创建命令流程
if (customCommand) {
// 替换 npm create vue@latest TARGET_DIR
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'
})
console.log(status, status ?? 0)
// 退出进程,0 正常,1 取消操作/异常
process.exit(status ?? 0)
}
console.log(`\nScaffolding project in ${root}...`)
4.5.1 pkgFromUserAgent 函数
// 获取包管理器名称和版本
function pkgFromUserAgent(userAgent: string | undefined) {
if (!userAgent) return undefined
const pkgSpec = userAgent.split(' ')[0]
const pkgSpecArr = pkgSpec.split('/')
return {
name: pkgSpecArr[0],
version: pkgSpecArr[1]
}
}
4.6 获取框架模板
// 理解 path.resolve
// 从后向前,若字符以 / 开头,不会拼接到前面的路径
// 若以 ../ 开头,拼接前面的路径,且不含最后一节路径
// 若连续出现多个 ../../.. 或者 ../.. 则忽略前方 .. 个路径名进行拼接
// 若以 ./ 开头或者没有符号 则拼接前面路径
// fileURLToPath: file:///xx/index.ts To /xx/index.ts
const templateDir = path.resolve(
fileURLToPath(import.meta.url),
'../..',
`template-${template}`
)
4.7 写入文件
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)
// 复制模板文件到用户指定目录,跳过 package.json 是因为需要修改 package name 后写入
for (const file of files.filter((f) => f !== 'package.json')) {
write(file)
}
// 模板框架 package.json 文件转为 JSON 对象
const pkg = JSON.parse(
fs.readFileSync(path.join(templateDir, `package.json`), 'utf-8')
)
// pkg name 为指定项目名称
pkg.name = packageName || getProjectName()
// 往指定项目中写入 package.json
write('package.json', JSON.stringify(pkg, null, 2))
4.7.1 copy 函数
// 复制文件和用copyDir复制文件夹
function copy(src: string, dest: string) {
// 获取文件信息状态实例
const stat = fs.statSync(src)
if (stat.isDirectory()) {
copyDir(src, dest)
} else {
fs.copyFileSync(src, dest)
}
}
4.7.2 copyDir 函数
// 复制文件夹
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)
}
}
4.8 安装完成后,打印提示信息
// 执行完成,提示可执行命令
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 总结
解读完 create-vite 源码,可以从中学到很多优秀的思想,不到 500 多行的源代码,非常轻便