create-vue Vue 团队公开的全新脚手架工具
用到的依赖包
- zx
https://juejin.cn/post/6979989936137043999
- prompts
用户命令行交互
- npm-run-all
同时运行多个 npm 命令
- minimist
解析命令行参数
- kolorist
命令行颜色
- esbuild
打包工具
- esbuild-plugin-license
json
{
"name": "create-vue",
"version": "3.5.0",
"description": "An easy way to start a Vue project",
"type": "module",
"bin": {
"create-vue": "outfile.cjs"
},
"files": ["outfile.cjs", "template"],
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"scripts": {
"prepare": "husky install",
"format": "prettier --write .",
"build": "zx ./scripts/build.mjs",
"snapshot": "zx ./scripts/snapshot.mjs",
"pretest": "run-s build snapshot",
"test": "zx ./scripts/test.mjs",
"prepublishOnly": "zx ./scripts/prepublish.mjs"
},
"repository": {
"type": "git",
"url": "git+https://github.com/vuejs/create-vue.git"
},
"keywords": [],
"author": "Haoqun Jiang <haoqunjiang+npm@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/vuejs/create-vue/issues"
},
"homepage": "https://github.com/vuejs/create-vue#readme",
"devDependencies": {
"@types/eslint": "^8.4.10",
"@types/node": "^18.11.12",
"@types/prompts": "^2.4.2",
"@vue/create-eslint-config": "^0.1.3",
"@vue/tsconfig": "^0.1.3",
"esbuild": "^0.14.53",
"esbuild-plugin-license": "^1.2.2",
"husky": "^8.0.2",
"kolorist": "^1.6.0",
"lint-staged": "^13.1.0",
"minimist": "^1.2.7",
"npm-run-all": "^4.1.5",
"prettier": "^2.8.1",
"prompts": "^2.4.2",
"zx": "^4.3.0"
},
"lint-staged": {
"*.{js,ts,vue,json}": ["prettier --write"]
}
}
从 package.json 可以看出 create-vue 的入口文件是"outfile.cjs",通过 build 构建生成,脚本在 script 文件夹中
先看下 build.mjs 文件
js
import * as esbuild from 'esbuild'
import esbuildPluginLicense from 'esbuild-plugin-license'
const CORE_LICENSE = `MIT License
Copyright (c) 2021-present vuejs
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
`
await esbuild.build({
bundle: true,
entryPoints: ['index.ts'],
outfile: 'outfile.cjs',
format: 'cjs',
platform: 'node',
target: 'node14',
plugins: [
{
name: 'alias',
setup({ onResolve, resolve }) {
onResolve({ filter: /^prompts$/, namespace: 'file' }, async ({ importer, resolveDir }) => {
// we can always use non-transpiled code since we support 14.16.0+
const result = await resolve('prompts/lib/index.js', { importer, resolveDir })
return result
})
},
},
esbuildPluginLicense({
thirdParty: {
includePrivate: false,
output: {
file: 'LICENSE',
template(allDependencies) {
// There's a bug in the plugin that it also includes the `create-vue` package itself
const dependencies = allDependencies.filter((d) => d.packageJson.name !== 'create-vue')
const licenseText =
`# create-vue core license\n\n` +
`create-vue is released under the MIT license:\n\n` +
CORE_LICENSE +
`\n## Licenses of bundled dependencies\n\n` +
`The published create-vue artifact additionally contains code with the following licenses:\n` +
[...new Set(dependencies.map((dependency) => dependency.packageJson.license))].join(
', ',
) +
'\n\n' +
`## Bundled dependencies\n\n` +
dependencies
.map((dependency) => {
return (
`## ${dependency.packageJson.name}\n\n` +
`License: ${dependency.packageJson.license}\n` +
`By: ${dependency.packageJson.author.name}\n` +
`Repository: ${dependency.packageJson.repository.url}\n\n` +
dependency.licenseText
.split('\n')
.map((line) => (line ? `> ${line}` : '>'))
.join('\n')
)
})
.join('\n\n')
return licenseText
},
},
},
}),
],
})
可以简单看出通过调用 esbuild 包来对文件进行打包然后生成打包后文件
在看下 prepublish.mjs
js
#!/usr/bin/env zx
import 'zx/globals'
await $`pnpm build`
await $`pnpm snapshot`
let { version } = JSON.parse(await fs.readFile('./package.json'))
const playgroundDir = path.resolve(__dirname, '../playground/')
cd(playgroundDir)
await $`pnpm install`
await $`git add -A .`
try {
await $`git commit -m "version ${version} snapshot"`
} catch (e) {
if (!e.stdout.includes('nothing to commit')) {
throw e
}
}
await $`git tag -m "v${version}" v${version}`
await $`git push --follow-tags`
const projectRoot = path.resolve(__dirname, '../')
cd(projectRoot)
await $`git add playground`
await $`git commit -m 'chore: update snapshot' --allow-empty`
await $`git push --follow-tags`
这个应该是上传包的前置命令,在发包之前会先执行这个脚本,通过 zx 达到自动执行 shell 的目的
index.ts
这个文件就是主要的脚手架文件了,看下文件内容
js
#!/usr/bin/env node
import * as fs from 'node:fs'
import * as path from 'node:path'
import minimist from 'minimist'
import prompts from 'prompts'
import { red, green, bold } from 'kolorist'
import renderTemplate from './utils/renderTemplate'
import { postOrderDirectoryTraverse, preOrderDirectoryTraverse } from './utils/directoryTraverse'
import generateReadme from './utils/generateReadme'
import getCommand from './utils/getCommand'
import renderEslint from './utils/renderEslint'
import banner from './utils/banner'
function isValidPackageName(projectName) {
return /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(projectName)
}
function toValidPackageName(projectName) {
return projectName
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/^[._]/, '')
.replace(/[^a-z0-9-~]+/g, '-')
}
function canSkipEmptying(dir: string) {
if (!fs.existsSync(dir)) {
return true
}
const files = fs.readdirSync(dir)
if (files.length === 0) {
return true
}
if (files.length === 1 && files[0] === '.git') {
return true
}
return false
}
function emptyDir(dir) {
if (!fs.existsSync(dir)) {
return
}
postOrderDirectoryTraverse(
dir,
(dir) => fs.rmdirSync(dir),
(file) => fs.unlinkSync(file)
)
}
async function init() {
console.log(`\n${banner}\n`)
const cwd = process.cwd()
// possible options:
// --default
// --typescript / --ts
// --jsx
// --router / --vue-router
// --pinia
// --with-tests / --tests (equals to `--vitest --cypress`)
// --vitest
// --cypress
// --playwright
// --eslint
// --eslint-with-prettier (only support prettier through eslint for simplicity)
// --force (for force overwriting)
const argv = minimist(process.argv.slice(2), {
alias: {
typescript: ['ts'],
'with-tests': ['tests'],
router: ['vue-router']
},
string: ['_'],
// all arguments are treated as booleans
boolean: true
})
// if any of the feature flags is set, we would skip the feature prompts
const isFeatureFlagsUsed =
typeof (
argv.default ??
argv.ts ??
argv.jsx ??
argv.router ??
argv.pinia ??
argv.tests ??
argv.vitest ??
argv.cypress ??
argv.playwright ??
argv.eslint
) === 'boolean'
let targetDir = argv._[0]
const defaultProjectName = !targetDir ? 'vue-project' : targetDir
const forceOverwrite = argv.force
let result: {
projectName?: string
shouldOverwrite?: boolean
packageName?: string
needsTypeScript?: boolean
needsJsx?: boolean
needsRouter?: boolean
needsPinia?: boolean
needsVitest?: boolean
needsE2eTesting?: false | 'cypress' | 'playwright'
needsEslint?: boolean
needsPrettier?: boolean
} = {}
try {
// Prompts:
// - Project name:
// - whether to overwrite the existing directory or not?
// - enter a valid package name for package.json
// - Project language: JavaScript / TypeScript
// - Add JSX Support?
// - Install Vue Router for SPA development?
// - Install Pinia for state management?
// - Add Cypress for testing?
// - Add Playwright for end-to-end testing?
// - Add ESLint for code quality?
// - Add Prettier for code formatting?
result = await prompts(
[
{
name: 'projectName',
type: targetDir ? null : 'text',
message: 'Project name:',
initial: defaultProjectName,
onState: (state) => (targetDir = String(state.value).trim() || defaultProjectName)
},
{
name: 'shouldOverwrite',
type: () => (canSkipEmptying(targetDir) || forceOverwrite ? null : 'confirm'),
message: () => {
const dirForPrompt =
targetDir === '.' ? 'Current directory' : `Target directory "${targetDir}"`
return `${dirForPrompt} is not empty. Remove existing files and continue?`
}
},
{
name: 'overwriteChecker',
type: (prev, values) => {
if (values.shouldOverwrite === false) {
throw new Error(red('✖') + ' Operation cancelled')
}
return null
}
},
{
name: 'packageName',
type: () => (isValidPackageName(targetDir) ? null : 'text'),
message: 'Package name:',
initial: () => toValidPackageName(targetDir),
validate: (dir) => isValidPackageName(dir) || 'Invalid package.json name'
},
{
name: 'needsTypeScript',
type: () => (isFeatureFlagsUsed ? null : 'toggle'),
message: 'Add TypeScript?',
initial: false,
active: 'Yes',
inactive: 'No'
},
{
name: 'needsJsx',
type: () => (isFeatureFlagsUsed ? null : 'toggle'),
message: 'Add JSX Support?',
initial: false,
active: 'Yes',
inactive: 'No'
},
{
name: 'needsRouter',
type: () => (isFeatureFlagsUsed ? null : 'toggle'),
message: 'Add Vue Router for Single Page Application development?',
initial: false,
active: 'Yes',
inactive: 'No'
},
{
name: 'needsPinia',
type: () => (isFeatureFlagsUsed ? null : 'toggle'),
message: 'Add Pinia for state management?',
initial: false,
active: 'Yes',
inactive: 'No'
},
{
name: 'needsVitest',
type: () => (isFeatureFlagsUsed ? null : 'toggle'),
message: 'Add Vitest for Unit Testing?',
initial: false,
active: 'Yes',
inactive: 'No'
},
{
name: 'needsE2eTesting',
type: () => (isFeatureFlagsUsed ? null : 'select'),
message: 'Add an End-to-End Testing Solution?',
initial: 0,
choices: (prev, answers) => [
{ title: 'No', value: false },
{
title: 'Cypress',
description: answers.needsVitest
? undefined
: 'also supports unit testing with Cypress Component Testing',
value: 'cypress'
},
{
title: 'Playwright',
value: 'playwright'
}
]
},
{
name: 'needsEslint',
type: () => (isFeatureFlagsUsed ? null : 'toggle'),
message: 'Add ESLint for code quality?',
initial: false,
active: 'Yes',
inactive: 'No'
},
{
name: 'needsPrettier',
type: (prev, values) => {
if (isFeatureFlagsUsed || !values.needsEslint) {
return null
}
return 'toggle'
},
message: 'Add Prettier for code formatting?',
initial: false,
active: 'Yes',
inactive: 'No'
}
],
{
onCancel: () => {
throw new Error(red('✖') + ' Operation cancelled')
}
}
)
} catch (cancelled) {
console.log(cancelled.message)
process.exit(1)
}
// `initial` won't take effect if the prompt type is null
// so we still have to assign the default values here
const {
projectName,
packageName = projectName ?? defaultProjectName,
shouldOverwrite = argv.force,
needsJsx = argv.jsx,
needsTypeScript = argv.typescript,
needsRouter = argv.router,
needsPinia = argv.pinia,
needsVitest = argv.vitest || argv.tests,
needsEslint = argv.eslint || argv['eslint-with-prettier'],
needsPrettier = argv['eslint-with-prettier']
} = result
const { needsE2eTesting } = result
const needsCypress = argv.cypress || argv.tests || needsE2eTesting === 'cypress'
const needsCypressCT = needsCypress && !needsVitest
const needsPlaywright = argv.playwright || needsE2eTesting === 'playwright'
const root = path.join(cwd, targetDir)
if (fs.existsSync(root) && shouldOverwrite) {
emptyDir(root)
} else if (!fs.existsSync(root)) {
fs.mkdirSync(root)
}
console.log(`\nScaffolding project in ${root}...`)
const pkg = { name: packageName, version: '0.0.0' }
fs.writeFileSync(path.resolve(root, 'package.json'), JSON.stringify(pkg, null, 2))
// todo:
// work around the esbuild issue that `import.meta.url` cannot be correctly transpiled
// when bundling for node and the format is cjs
// const templateRoot = new URL('./template', import.meta.url).pathname
const templateRoot = path.resolve(__dirname, 'template')
const render = function render(templateName) {
const templateDir = path.resolve(templateRoot, templateName)
renderTemplate(templateDir, root)
}
// Render base template
render('base')
// Add configs.
if (needsJsx) {
render('config/jsx')
}
if (needsRouter) {
render('config/router')
}
if (needsPinia) {
render('config/pinia')
}
if (needsVitest) {
render('config/vitest')
}
if (needsCypress) {
render('config/cypress')
}
if (needsCypressCT) {
render('config/cypress-ct')
}
if (needsPlaywright) {
render('config/playwright')
}
if (needsTypeScript) {
render('config/typescript')
// Render tsconfigs
render('tsconfig/base')
if (needsCypress) {
render('tsconfig/cypress')
}
if (needsCypressCT) {
render('tsconfig/cypress-ct')
}
if (needsPlaywright) {
render('tsconfig/playwright')
}
if (needsVitest) {
render('tsconfig/vitest')
}
}
// Render ESLint config
if (needsEslint) {
renderEslint(root, { needsTypeScript, needsCypress, needsCypressCT, needsPrettier })
}
// Render code template.
// prettier-ignore
const codeTemplate =
(needsTypeScript ? 'typescript-' : '') +
(needsRouter ? 'router' : 'default')
render(`code/${codeTemplate}`)
// Render entry file (main.js/ts).
if (needsPinia && needsRouter) {
render('entry/router-and-pinia')
} else if (needsPinia) {
render('entry/pinia')
} else if (needsRouter) {
render('entry/router')
} else {
render('entry/default')
}
// Cleanup.
// We try to share as many files between TypeScript and JavaScript as possible.
// If that's not possible, we put `.ts` version alongside the `.js` one in the templates.
// So after all the templates are rendered, we need to clean up the redundant files.
// (Currently it's only `cypress/plugin/index.ts`, but we might add more in the future.)
// (Or, we might completely get rid of the plugins folder as Cypress 10 supports `cypress.config.ts`)
if (needsTypeScript) {
// Convert the JavaScript template to the TypeScript
// Check all the remaining `.js` files:
// - If the corresponding TypeScript version already exists, remove the `.js` version.
// - Otherwise, rename the `.js` file to `.ts`
// Remove `jsconfig.json`, because we already have tsconfig.json
// `jsconfig.json` is not reused, because we use solution-style `tsconfig`s, which are much more complicated.
preOrderDirectoryTraverse(
root,
() => {},
(filepath) => {
if (filepath.endsWith('.js')) {
const tsFilePath = filepath.replace(/\.js$/, '.ts')
if (fs.existsSync(tsFilePath)) {
fs.unlinkSync(filepath)
} else {
fs.renameSync(filepath, tsFilePath)
}
} else if (path.basename(filepath) === 'jsconfig.json') {
fs.unlinkSync(filepath)
}
}
)
// Rename entry in `index.html`
const indexHtmlPath = path.resolve(root, 'index.html')
const indexHtmlContent = fs.readFileSync(indexHtmlPath, 'utf8')
fs.writeFileSync(indexHtmlPath, indexHtmlContent.replace('src/main.js', 'src/main.ts'))
} else {
// Remove all the remaining `.ts` files
preOrderDirectoryTraverse(
root,
() => {},
(filepath) => {
if (filepath.endsWith('.ts')) {
fs.unlinkSync(filepath)
}
}
)
}
// Instructions:
// Supported package managers: pnpm > yarn > npm
const userAgent = process.env.npm_config_user_agent ?? ''
const packageManager = /pnpm/.test(userAgent) ? 'pnpm' : /yarn/.test(userAgent) ? 'yarn' : 'npm'
// README generation
fs.writeFileSync(
path.resolve(root, 'README.md'),
generateReadme({
projectName: result.projectName ?? result.packageName ?? defaultProjectName,
packageManager,
needsTypeScript,
needsVitest,
needsCypress,
needsPlaywright,
needsCypressCT,
needsEslint
})
)
console.log(`\nDone. Now run:\n`)
if (root !== cwd) {
console.log(` ${bold(green(`cd ${path.relative(cwd, root)}`))}`)
}
console.log(` ${bold(green(getCommand(packageManager, 'install')))}`)
if (needsPrettier) {
console.log(` ${bold(green(getCommand(packageManager, 'lint')))}`)
}
console.log(` ${bold(green(getCommand(packageManager, 'dev')))}`)
console.log()
}
init().catch((e) => {
console.error(e)
})