- 本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。
- 第37期 | vite 3.0 都发布了,这次来手撕 create-vite 源码
前言
不是吧,不是吧,vite在7月份都发布3.0了,你却还跟我这个大怨种一样停留在只会enter create-vite新建项目😭?更别提要开发自己的脚手架了,要知道知其然才能更好的知其所以然,今天就来一起捋捋create-vite的源码,掌握一下它的原理吧!
任务清单
这一part虽然很鸡肋,但是通关式的学习很有成就感哇~
- 下载调试create-vite源码
- 分析源码
- 总结归纳
学前准备
2.1 npm create
老规矩,学习源码前要养成使用说明 README 的好习惯,浅看一下用法:
用我蹩脚的英文翻译一下就是vite对node.js环境的要求是14.18+, 16+,有些甚至要求更高一点,然后用法是
npm create vite,emmm...,npm init、npm install见惯不怪了,npm create好像有那么点陌生,那就在npm Docs上查查资料,原来npm create是npm init的另一种叫法,涨芝士了~
顺带记一下怎么查看最新版本的
npm dist-tag ls create-vite
// latest: 3.0.2
2.2 克隆库
// 以下两种方式都可以
// ①官网是ts版本的
git clone https://github.com/vitejs/vite.git
cd vite/packages/create-vite
// 全局安装pnpm npm i pnpm -g
pnpm install
// ②习惯用js的也可以直接克隆若川大佬的库
git clone https://github.com/lxchuan12/vite-analysis.git
cd vite-analysis/vite2
# npm i -g pnpm pnpm install
# 在这个 index.js 文件中断点
# 在命令行终端调试
node vite2/packages/create-vite/index.js
vite的CONTRIBUTING.md 还详细写了debug方法,可以说很贴心了
- 开启
script调试 - 开启调试后执行
pnpm run dev生成dist/index.mjs2.3 调试ts
- 由于3.0.2是用ts写的,顺带记录一下如何用vscode调试typescript,先是安装了一下扩展@category:debuggers TypeScript,不过我通过launch.json调试是一直不生效的,有好的解决方案的也欢迎多多指教哇~
- 下面推荐生效的调试方式
// ① 安装ts-node typescript
pnpm i ts-node typescript -D
node --loader ts-node/esm ./src/index.ts
// ② 通过esno执行ts
npx esno src/index.ts
- ts-node调试截图
- esno 运行截图
源码分析
3.1 引入依赖
// 文件模块
import fs from 'node:fs'
// 路径模块
import path from 'node:path'
// url模块 fileURLToPath函数将文件URL解码为路径字符串,并确保在将给定的文件URL转换为路径时正确地附加/调整了URL控制字符(/,%)
import { fileURLToPath } from 'node:url'
// 根据node.js的子进程(child_process)模块下的spawn函数封装,可以在调用 spawn 函数时,自动根据当前的运行平台,来决定是否生成一个 shell 来执行所给的命令
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'
3.2 定义关键参数
// 命令行选项对象
const argv = minimist<{
t?: string
template?: string
}>(process.argv.slice(2), { string: ['_'] })
// node.js 进程执行时的文件夹地址
const cwd = process.cwd()
// 颜色函数类型
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'
}
]
}
]
// 模板名字数组
const TEMPLATES = FRAMEWORKS.map(
(f) => (f.variants && f.variants.map((v) => v.name)) || [f.name]
).reduce((a, b) => a.concat(b), [])
// 重命名文件
const renameFiles: Record<string, string | undefined> = {
_gitignore: '.gitignore'
}
// 默认目标路径
const defaultTargetDir = 'vite-project'
4 init函数拆分
- 4.1 输出目标路径
const argTargetDir = formatTargetDir(argv._[0])
const argTemplate = argv.template || argv.t
let targetDir = argTargetDir || defaultTargetDir
const getProjectName = () =>
targetDir === '.' ? path.basename(path.resolve()) : targetDir
formatTargetDir函数替代 / 为 '',trim()去空格,
function formatTargetDir(targetDir: string | undefined) {
return targetDir?.trim().replace(/\/+$/g, '')
}
- 4.2 询问项目名\是否重写路径\包名\框架\变体等
framework:框架, overwrite:是否重写目录, packageName:包名, variant:变体,如vue - vue-ts
let result: prompts.Answers<
'projectName' | 'overwrite' | 'packageName' | 'framework' | 'variant'
>
try {
result = await prompts(
[
{
type: argTargetDir ? null : 'text',
name: 'projectName',
message: reset('Project name:'),
initial: defaultTargetDir,
onState: (state) => {
targetDir = formatTargetDir(state.value) || defaultTargetDir
}
},
...省略代码
],
{
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
- 4.3 重写目录/创建空目录实现
const root = path.join(cwd, targetDir)
if (overwrite) {
// 目录存在则递归删除
emptyDir(root)
} else if (!fs.existsSync(root)) {
// 新建目录
fs.mkdirSync(root, { recursive: true })
}
递归删除已有目录
function emptyDir(dir: string) {
if (!fs.existsSync(dir)) {
return
}
for (const file of fs.readdirSync(dir)) {
if (file === '.git') {
continue
}
// recursive是否递归
fs.rmSync(path.resolve(dir, file), { recursive: true, force: true })
}
}
- 4.5 获取模板路径信息,若有自定义命令则批处理执行
// determine template
const template: string = variant || framework || argTemplate
// 获取用户包管理器信息
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) {
// 若有定义命令则根据包信息替换变体的执行命令,这里用spawn批处理执行命令
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)
}
console.log(`\nScaffolding project in ${root}...`)
const templateDir = path.resolve(
fileURLToPath(import.meta.url),
'../..',
`template-${template}`
)
- 4.6 写入函数
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)
}
}
copy&©Dir函数
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)
}
}
- 4.7 根据模板路径的文件依次写入目标文件
const files = fs.readdirSync(templateDir)
for (const file of files.filter((f) => f !== 'package.json')) {
write(file)
}
const pkg = JSON.parse(
fs.readFileSync(path.join(templateDir, `package.json`), 'utf-8')
)
pkg.name = packageName || getProjectName()
// package.json单独处理
write('package.json', JSON.stringify(pkg, null, 2))
- 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()
}
运行结果截图
总结
今天运行调试了create-vite3.0.2的源码,分析了脚手架的大体实现思路,感谢若川大佬的指导才第一次完整地实践了调试源码中的ts代码,边调试边分析才能更好地掌握其原理,言归正传,create-vite的主要实现还是依靠node.js的文件模块达到写入文件的目的,3.0.2较3.0.0版本增加了cross-spawn批处理命令外还改用了ts书写,不止create-vite其实大多数插件也越来越偏向于使用ts代替js,这也启发我们在日常项目中也可以用ts规范书写,当然前端想要开发脚手架学好node.js还是yyds