背景
写这个工具的起因是因为公司一些新建项目的时候都会从老的项目中cv一套出来,然后把里面的代码删掉,重新在这个基础上开发。这样造成了很多的问题,比如1. 上一个代码并没有删除完全,很多无用代码影响的项目的维护和可读性。2. 人工的手动搬运和修改很容易造成意想不到的问题。等等这些问题我就打算自己写一个创建项目的脚手架帮助大家来快速新建项目。
文章结构
这篇文章会从以下几点来介绍
create-vite源码
我是直接从github上clone的vite的源码,因为要学vite其他的东西嘛,索性一起下下来了。create-vite的结构接在这里
可以看到很多都是模板文件。而核心代码就在src/index.ts里面。一共504行代码。这个我们一会再说,先来看看这个项目的package.json。
package.json
create-vite里的package.json很简单,这里记录较重要的点bin,files,scripts,devDependencies。
bin
"bin": {
"create-vite": "index.js",
"cva": "index.js"
},
bin里面的命令就是咱们使用vite创建项目时会使用到的命令。比如全局安装了vite后,在终端执行create-vite,就会执行create-vite中dist文件夹下的index.js文件去执行创建项目的逻辑。如果新建一个脚手架也可以定义自己的脚本命令。
files
"files": [
"index.js",
"template-*",
"dist"
],
这个字段用我自己的理解就是当你发布一个npm包时所要包含的文件。这个files字段的优先级最大不会=被npmignore和.gitignore覆盖。就比如我不想把我src下的开发源码一起发到npm包上去,那我就在fules里面不包含src文件夹,这样上传后不仅减少了体积,还避免了一些开发源码泄露的问题。
scripts
"scripts": {
"dev": "unbuild --stub",
"build": "unbuild",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run build"
},
这里的unbuild在create-vite包里是找不到安装依赖的,因为vite用的monorepo架构的,所以unbuild依赖是放在最外层的package.json中的。而unbuild跟rollup类似,是一个js库打包工具。这里就不详细介绍啦,有兴趣的可以去看一下:unbuild。这里还要写一个对应的build.config.ts配置文件。来配置打包规则。
devDependencies
"devDependencies": {
"@types/minimist": "^1.2.2",
"@types/prompts": "^2.4.4",
"cross-spawn": "^7.0.3",
"kolorist": "^1.8.0",
"minimist": "^1.2.8",
"prompts": "^2.4.2"
}
两个声明文件包不做过多介绍
- cross-spawn 自动根据运行平台(windows、mac、linux 等)生成 shell 命令,并执行
- minimist 解析命令行传入的参数;
- prompts 命令行交互提示;
- kolorist 给输入输出上颜色;
这几个都要用到,没啥好说的。接下来直接看src/index.ts里的核心内容
src/index.ts
这里补充一下我的调试方法。首先在create-vite文件夹起一个终端,然后运行pnpm run dev命令,这样就进入到开发模式了,接着终端执行 node dist/index.mjs 来执行代码。
index.ts文件中最核心的函数就是init函数。直接从这里看
async function init() {
const argTargetDir = formatTargetDir(argv._[0]) // 获取传入参数中的项目名称
const argTemplate = argv.template || argv.t // 获取模板名称
let targetDir = argTargetDir || defaultTargetDir
const getProjectName = () =>
targetDir === '.' ? path.basename(path.resolve()) : targetDir
let result: prompts.Answers
....
}
一开始定义了一些变量,接收命令行的参数,比如我输入这样一行命令
node dist/index.mjs my-vue-app --template vue
那么argTargetDir就是"my-vue-app", argTemplate就是"vue"。这两个就是取命令行里的参数,其他两个没啥好讲的,语义就能看懂。
接下来就是使用prompts来进行命令行交互,一大堆交互逻辑,这里也不展开细讲。
拿到交互结果以后,就要去创建文件夹和进行写入了。这一段代码就是创建文件夹的逻辑,判断了是否重写还是创建新文件夹。
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 })
}
这一段就是确认使用的模板
// determine template
let template: string = variant || framework?.name || argTemplate
let isReactSwc = false
if (template.includes('-swc')) {
isReactSwc = true
template = template.replace('-swc', '')
}
这一段是确认包管理器
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)
}
这一段就是开始进行写入操作了
console.log(`nScaffolding project in ${root}...`)
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)
}
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')
if (isReactSwc) {
setupReactSwc(root, template.endsWith('-ts'))
}
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
}
主要的步骤和环节就梳理完全啦,还有一些不在init中定义的方法我在这里贴出来,
function formatTargetDir(targetDir: string | undefined) {
return targetDir?.trim().replace(//+$/g, '')
}
function copy(src: string, dest: string) {
const stat = fs.statSync(src)
if (stat.isDirectory()) {
copyDir(src, dest)
} else {
fs.copyFileSync(src, dest)
}
}
function isValidPackageName(projectName: string) {
return /^(?:@[a-zd-*~][a-zd-*._~]*/)?[a-zd-~][a-zd-._~]*$/.test(
projectName,
)
}
function toValidPackageName(projectName: string) {
return projectName
.trim()
.toLowerCase()
.replace(/s+/g, '-')
.replace(/^[._]/, '')
.replace(/[^a-zd-~]+/g, '-')
}
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 isEmpty(path: string) {
const files = fs.readdirSync(path)
return files.length === 0 || (files.length === 1 && files[0] === '.git')
}
function emptyDir(dir: string) {
if (!fs.existsSync(dir)) {
return
}
for (const file of fs.readdirSync(dir)) {
if (file === '.git') {
continue
}
fs.rmSync(path.resolve(dir, file), { recursive: true, force: true })
}
}
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],
}
}
function setupReactSwc(root: string, isTs: boolean) {
editFile(path.resolve(root, 'package.json'), (content) => {
return content.replace(
/"@vitejs/plugin-react": ".+?"/,
`"@vitejs/plugin-react-swc": "^3.0.0"`,
)
})
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')
}
以上就是对create-vite中index.ts文件的分析。记录的有点简单,好在代码不难看懂,建议大家自己down下来看一下,地址:vite。create-vite。
下篇开坑我如何从0到1搭建这个项目的。就酱,拜!