# create-vue Vue 团队公开的全新脚手架工具
美国时间 2021 年 10 月 7 日早晨,Vue 团队等主要贡献者举办了一个 Vue Contributor Days 在线会议,蒋豪群(知乎胖茶,Vue.js 官方团队成员,Vue-CLI 核心开发),在会上公开了create-vue,一个全新的脚手架工具。
# craete vue
An easy way to start a Vue project
npm init vue@next
# npm init
npm init 文档 (opens new window)
执行 npm init vue@next
, 相当于执行 npx create-vue@next
# 源码解析
克隆下项目仓库
git clone https://github.com/vuejs/create-vue
# package.json
bin:
"bin": {
"create-vue": "outfile.cjs"
},
bin指的可执行二进制文件,这也是我们使用 create-vue的原因。
scripts:
"scripts": {
"prepare": "husky install",
"format": "prettier --write .",
"build": "esbuild --bundle index.js --format=cjs --platform=node --outfile=outfile.cjs",
"snapshot": "node snapshot.js",
"pretest": "run-s build snapshot",
"test": "node test.js",
"prepublishOnly": "run-s build snapshot"
},
# index.js
源码
#!/usr/bin/env node
// @ts-check
import fs from 'fs'
import path from 'path'
import minimist from 'minimist'
import prompts from 'prompts'
import { red, green, bold } from 'kolorist'
import renderTemplate from './utils/renderTemplate.js'
import { postOrderDirectoryTraverse, preOrderDirectoryTraverse } from './utils/directoryTraverse.js'
import generateReadme from './utils/generateReadme.js'
import getCommand from './utils/getCommand.js'
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 canSafelyOverwrite(dir) {
return !fs.existsSync(dir) || fs.readdirSync(dir).length === 0
}
function emptyDir(dir) {
postOrderDirectoryTraverse(
dir,
(dir) => fs.rmdirSync(dir),
(file) => fs.unlinkSync(file)
)
}
async function init() {
const cwd = process.cwd()
// possible options:
// --default
// --typescript / --ts
// --jsx
// --router / --vue-router
// --vuex
// --with-tests / --tests / --cypress
// --force (for force overwriting)
const argv = minimist(process.argv.slice(2), {
alias: {
typescript: ['ts'],
'with-tests': ['tests', 'cypress'],
router: ['vue-router']
},
// all arguments are treated as booleans
boolean: true
})
// if any of the feature flags is set, we would skip the feature prompts
// use `??` instead of `||` once we drop Node.js 12 support
const isFeatureFlagsUsed =
typeof (argv.default || argv.ts || argv.jsx || argv.router || argv.vuex || argv.tests) ===
'boolean'
let targetDir = argv._[0]
const defaultProjectName = !targetDir ? 'vue-project' : targetDir
const forceOverwrite = argv.force
let result = {}
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 Vuex for state management? (TODO)
// - Add Cypress for testing?
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: () => (canSafelyOverwrite(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: 'needsVuex',
type: () => (isFeatureFlagsUsed ? null : 'toggle'),
message: 'Add Vuex for state management?',
initial: false,
active: 'Yes',
inactive: 'No'
},
{
name: 'needsTests',
type: () => (isFeatureFlagsUsed ? null : 'toggle'),
message: 'Add Cypress for testing?',
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 {
packageName = toValidPackageName(defaultProjectName),
shouldOverwrite,
needsJsx = argv.jsx,
needsTypeScript = argv.typescript,
needsRouter = argv.router,
needsVuex = argv.vuex,
needsTests = argv.tests
} = result
const root = path.join(cwd, targetDir)
if (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 (needsVuex) {
render('config/vuex')
}
if (needsTests) {
render('config/cypress')
}
if (needsTypeScript) {
render('config/typescript')
}
// Render code template.
// prettier-ignore
const codeTemplate =
(needsTypeScript ? 'typescript-' : '') +
(needsRouter ? 'router' : 'default')
render(`code/${codeTemplate}`)
// Render entry file (main.js/ts).
if (needsVuex && needsRouter) {
render('entry/vuex-and-router')
} else if (needsVuex) {
render('entry/vuex')
} else if (needsRouter) {
render('entry/router')
} else {
render('entry/default')
}
// Cleanup.
if (needsTypeScript) {
// rename all `.js` files to `.ts`
// rename jsconfig.json to tsconfig.json
preOrderDirectoryTraverse(
root,
() => {},
(filepath) => {
if (filepath.endsWith('.js')) {
fs.renameSync(filepath, filepath.replace(/\.js$/, '.ts'))
} else if (path.basename(filepath) === 'jsconfig.json') {
fs.renameSync(filepath, filepath.replace(/jsconfig\.json$/, 'tsconfig.json'))
}
}
)
// 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'))
}
if (!needsTests) {
// All templates assumes the need of tests.
// If the user doesn't need it:
// rm -rf cypress **/__tests__/
preOrderDirectoryTraverse(
root,
(dirpath) => {
const dirname = path.basename(dirpath)
if (dirname === 'cypress' || dirname === '__tests__') {
emptyDir(dirpath)
fs.rmdirSync(dirpath)
}
},
() => {}
)
}
// Instructions:
// Supported package managers: pnpm > yarn > npm
// Note: until <https://github.com/pnpm/pnpm/issues/3505> is resolved,
// it is not possible to tell if the command is called by `pnpm init`.
const packageManager = /pnpm/.test(process.env.npm_execpath)
? 'pnpm'
: /yarn/.test(process.env.npm_execpath)
? 'yarn'
: 'npm'
// README generation
fs.writeFileSync(
path.resolve(root, 'README.md'),
generateReadme({
projectName: result.projectName || defaultProjectName,
packageManager,
needsTypeScript,
needsTests
})
)
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')))}`)
console.log(` ${bold(green(getCommand(packageManager, 'dev')))}`)
console.log()
}
init().catch((e) => {
console.error(e)
})
可运行主要函数:
init().catch((e) => {
console.error(e)
})
# init函数
init 函数
async function init(){
const cwd = process.cwd()
// possible options:
// --default
// --typescript / --ts
// --jsx
// --router / --vue-router
// --vuex
// --with-tests / --tests / --cypress
// --force (for force overwriting)
// 拿到 命令行中的参数
const argv = minimist(process.argv.slice(2), {
alias: {
typescript: ['ts'],
'with-tests': ['tests', 'cypress'],
router: ['vue-router']
},
// all arguments are treated as booleans
boolean: true
})
// if any of the feature flags is set, we would skip the feature prompts
// use `??` instead of `||` once we drop Node.js 12 support
const isFeatureFlagsUsed =
typeof (argv.default || argv.ts || argv.jsx || argv.router || argv.vuex || argv.tests) ===
'boolean'
let targetDir = argv._[0]
const defaultProjectName = !targetDir ? 'vue-project' : targetDir
const forceOverwrite = argv.force
let result = {}
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 Vuex for state management? (TODO)
// - Add Cypress for testing?
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: () => (canSafelyOverwrite(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: 'needsVuex',
type: () => (isFeatureFlagsUsed ? null : 'toggle'),
message: 'Add Vuex for state management?',
initial: false,
active: 'Yes',
inactive: 'No'
},
{
name: 'needsTests',
type: () => (isFeatureFlagsUsed ? null : 'toggle'),
message: 'Add Cypress for testing?',
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 {
packageName = toValidPackageName(defaultProjectName),
shouldOverwrite,
needsJsx = argv.jsx,
needsTypeScript = argv.typescript,
needsRouter = argv.router,
needsVuex = argv.vuex,
needsTests = argv.tests
} = result
const root = path.join(cwd, targetDir)
if (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 (needsVuex) {
render('config/vuex')
}
if (needsTests) {
render('config/cypress')
}
if (needsTypeScript) {
render('config/typescript')
}
// Render code template.
// prettier-ignore
const codeTemplate =
(needsTypeScript ? 'typescript-' : '') +
(needsRouter ? 'router' : 'default')
render(`code/${codeTemplate}`)
// Render entry file (main.js/ts).
if (needsVuex && needsRouter) {
render('entry/vuex-and-router')
} else if (needsVuex) {
render('entry/vuex')
} else if (needsRouter) {
render('entry/router')
} else {
render('entry/default')
}
// Cleanup.
if (needsTypeScript) {
// rename all `.js` files to `.ts`
// rename jsconfig.json to tsconfig.json
preOrderDirectoryTraverse(
root,
() => {},
(filepath) => {
if (filepath.endsWith('.js')) {
fs.renameSync(filepath, filepath.replace(/\.js$/, '.ts'))
} else if (path.basename(filepath) === 'jsconfig.json') {
fs.renameSync(filepath, filepath.replace(/jsconfig\.json$/, 'tsconfig.json'))
}
}
)
// 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'))
}
if (!needsTests) {
// All templates assumes the need of tests.
// If the user doesn't need it:
// rm -rf cypress **/__tests__/
preOrderDirectoryTraverse(
root,
(dirpath) => {
const dirname = path.basename(dirpath)
if (dirname === 'cypress' || dirname === '__tests__') {
emptyDir(dirpath)
fs.rmdirSync(dirpath)
}
},
() => {}
)
}
// Instructions:
// Supported package managers: pnpm > yarn > npm
// Note: until <https://github.com/pnpm/pnpm/issues/3505> is resolved,
// it is not possible to tell if the command is called by `pnpm init`.
const packageManager = /pnpm/.test(process.env.npm_execpath)
? 'pnpm'
: /yarn/.test(process.env.npm_execpath)
? 'yarn'
: 'npm'
// README generation
fs.writeFileSync(
path.resolve(root, 'README.md'),
generateReadme({
projectName: result.projectName || defaultProjectName,
packageManager,
needsTypeScript,
needsTests
})
)
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')))}`)
console.log(` ${bold(green(getCommand(packageManager, 'dev')))}`)
console.log()
}
# 拿到命令行中的参数
// process.cwd() 方法返回 Node.js 进程的当前工作目录。
const cwd = process.cwd()
// 可出现的命令选项:
// --default
// --typescript / --ts
// --jsx
// --router / --vue-router
// --vuex
// --with-tests / --tests / --cypress
// --force (for force overwriting)
// process.argv 属性返回数组,其中包含启动 Node.js 进程时传入的命令行参数。
// 第一个元素将是 process.execPath。 如果需要访问 argv[0] 的原始值,请参阅 process.argv0。
// 第二个元素将是正在执行的 JavaScript 文件的路径。 其余元素将是任何其他命令行参数。
const argv = minimist(process.argv.slice(2), {
alias: {
typescript: ['ts'],
'with-tests': ['tests', 'cypress'],
router: ['vue-router']
},
// all arguments are treated as booleans
boolean: true
})
// if any of the feature flags is set, we would skip the feature prompts
// use `??` instead of `||` once we drop Node.js 12 support
const isFeatureFlagsUsed =
typeof (argv.default || argv.ts || argv.jsx || argv.router || argv.vuex || argv.tests) ===
'boolean'
// argv._ 包含了所有未被关联的参数命令
let targetDir = argv._[0]
// 默认项目文件名
const defaultProjectName = !targetDir ? 'vue-project' : targetDir
// 强制,则意味着覆盖文件
const forceOverwrite = argv.force
minimist (opens new window),用于解析命令中的参数。
# prompts 命令行交互式获取参数
这一步,根据npm init vue@next式出现的交互信息:
- 项目的文件名 projectName
- 是否需要 typescript
- 是否需要jsx
- 是否需要router
- 是否需要vuex
- 是否需要测试
通过 prompts 进行交互后,将数据 写入 result 中。
# 根据命令行和交互中获取的信息进行创建
这里涉及到了 解构赋值并赋初始值的 语法。
//关于 解构并赋初始值
const {
packageName = toValidPackageName(defaultProjectName),
shouldOverwrite,
needsJsx = argv.jsx,
needsTypeScript = argv.typescript,
needsRouter = argv.router,
needsVuex = argv.vuex,
needsTests = argv.tests
} = result
// 拿到 项目 文件夹目录
const root = path.join(cwd, targetDir)
// 判断是否要重写
if (shouldOverwrite) {
emptyDir(root)
// 判断文件夹是否已存在
} else if (!fs.existsSync(root)) {
// 同步方式创建一个文件夹
fs.mkdirSync(root)
}
console.log(`\nScaffolding project in ${root}...`)
# 根据参数渲染配置文件
这里主要的函数 renderTemplate,会在后面进行介绍。
const pkg = { name: packageName, version: '0.0.0' }
// 写入 package.json 文件夹
fs.writeFileSync(path.resolve(root, 'package.json'), JSON.stringify(pkg, null, 2))
const templateRoot = path.resolve(__dirname, 'template')
// 拼接 模板路径 调用 renderTemplate 进行渲染
const render = function render(templateName) {
const templateDir = path.resolve(templateRoot, templateName)
renderTemplate(templateDir, root)
}
// Render template
render('base')
// Add configs. 渲染添加配置文件
if (needsJsx) {
render('config/jsx')
}
if (needsRouter) {
render('config/router')
}
if (needsVuex) {
render('config/vuex')
}
if (needsTests) {
render('config/cypress')
}
if (needsTypeScript) {
render('config/typescript')
}
# 渲染代码
这里根据参数去添加对应的配置文件
// prettier-ignore
// 渲染模板代码
const codeTemplate = (needsTypeScript ? 'typescript-' : '') + (needsRouter ? 'router' : 'default')
render(`code/${codeTemplate}`)
// Render entry file (main.js/ts).
if (needsVuex && needsRouter) {
render('entry/vuex-and-router')
} else if (needsVuex) {
render('entry/vuex')
} else if (needsRouter) {
render('entry/router')
} else {
render('entry/default')
}
# ts项目的处理
如果需要使用到TS,这里会将前面渲染得到的所有js文件,将后缀替换TS,同时将 jsconfig.json
文件名替换成 tsconfig.json
重新修改 index.html
中 mian.js 的 文件。
if (needsTypeScript) {
// rename all `.js` files to `.ts`
// rename jsconfig.json to tsconfig.json
preOrderDirectoryTraverse(
root,
() => {},
(filepath) => {
if (filepath.endsWith('.js')) {
fs.renameSync(filepath, filepath.replace(/\.js$/, '.ts'))
} else if (path.basename(filepath) === 'jsconfig.json') {
fs.renameSync(filepath, filepath.replace(/jsconfig\.json$/, 'tsconfig.json'))
}
}
)
// 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'))
}
# 项目测试的处理
一般所有渲染,会自带测试,如果不需要,会进行删除。
if (!needsTests) {
// All templates assumes the need of tests.
// If the user doesn't need it:
// rm -rf cypress **/__tests__/
preOrderDirectoryTraverse(
root,
(dirpath) => {
const dirname = path.basename(dirpath)
if (dirname === 'cypress' || dirname === '__tests__') {
emptyDir(dirpath)
fs.rmdirSync(dirpath)
}
},
() => {}
)
}
# 包管理的处理
支持的包管理: pnpm > yarn > npm
// Instructions:
// Supported package managers: pnpm > yarn > npm
// Note: until <https://github.com/pnpm/pnpm/issues/3505> is resolved,
// it is not possible to tell if the command is called by `pnpm init`.
const packageManager = /pnpm/.test(process.env.npm_execpath)
? 'pnpm'
: /yarn/.test(process.env.npm_execpath)
? 'yarn'
: 'npm'
// README generation
fs.writeFileSync(
path.resolve(root, 'README.md'),
generateReadme({
projectName: result.projectName || defaultProjectName,
packageManager,
needsTypeScript,
needsTests
})
)
# 关于包名
isValidPackageName 这个函数用于检验包名是否合法。
function isValidPackageName(projectName) {
// 正则表达式
return /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(projectName)
}
toValidPackageName 这个函数用于将项目名变得合法
function toValidPackageName(projectName) {
return projectName
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/^[._]/, '')
.replace(/[^a-z0-9-~]+/g, '-')
}
# postOrderDirectoryTraverse 与 preOrderDirectoryTraverse
这两个函数在 utils/directoryTraverse.js
中, 同时根据函数名的推测,一个是后序遍历文件夹,一个是先序遍历文件夹。
# postOrderDirectoryTraverse
function postOrderDirectoryTraverse(dir, dirCallback, fileCallback) {
for (const filename of fs.readdirSync(dir)) {
// 得到路径
const fullpath = path.resolve(dir, filename)
// 判断路径是否为文件夹,如果是则递归调用, 然后再调用 dirCall
if (fs.lstatSync(fullpath).isDirectory()) {
postOrderDirectoryTraverse(fullpath, dirCallback, fileCallback)
dirCallback(fullpath)
continue
}
fileCallback(fullpath)
}
}
# preOrderDirectoryTraverse
function preOrderDirectoryTraverse(dir, dirCallback, fileCallback) {
for (const filename of fs.readdirSync(dir)) {
const fullpath = path.resolve(dir, filename)
if (fs.lstatSync(fullpath).isDirectory()) {
// 先调用 dirCall 再递归
dirCallback(fullpath)
// in case the dirCallback removes the directory entirely
if (fs.existsSync(fullpath)) {
preOrderDirectoryTraverse(fullpath, dirCallback, fileCallback)
}
continue
}
fileCallback(fullpath)
}
}
# emptyDir
这里调用前面了 postOrderDirectoryTraverse 函数, 先删除了文件,再删除了文件夹,已达到清空路径的作用。
function emptyDir(dir) {
postOrderDirectoryTraverse(
dir,
(dir) => fs.rmdirSync(dir),
(file) => fs.unlinkSync(file)
)
}
# renderTemplate
最重要的函数之一,生成函数代码和文件,基本上都是它来完成的,代码在 utils/renderTemplate.js
import fs from 'fs'
import path from 'path'
import deepMerge from './deepMerge.js'
import sortDependencies from './sortDependencies.js'
/**
* Renders a template folder/file to the file system,
* by recursively copying all files under the `src` directory,
* with the following exception:
* - `_filename` should be renamed to `.filename`
* - Fields in `package.json` should be recursively merged
* @param {string} src source filename to copy
* @param {string} dest destination filename of the copy operation
*/
function renderTemplate(src, dest) {
const stats = fs.statSync(src)
if (stats.isDirectory()) {
// if it's a directory, render its subdirectories and files recusively
fs.mkdirSync(dest, { recursive: true })
for (const file of fs.readdirSync(src)) {
renderTemplate(path.resolve(src, file), path.resolve(dest, file))
}
return
}
// path.basename() 方法返回 path 的最后一部分 即 最后一个 / 后面的东西
const filename = path.basename(src)
// 如果 packjson.json 文件已存在,则进行合并而不是重写
if (filename === 'package.json' && fs.existsSync(dest)) {
// merge instead of overwriting
// 因为 package.json 文件,所以可以进行转换成 js 对象
const existing = JSON.parse(fs.readFileSync(dest))
const newPackage = JSON.parse(fs.readFileSync(src))
const pkg = sortDependencies(deepMerge(existing, newPackage))
// 写入新的package.json文件
fs.writeFileSync(dest, JSON.stringify(pkg, null, 2) + '\n')
return
}
if (filename.startsWith('_')) {
// rename `_file` to `.file`
dest = path.resolve(path.dirname(dest), filename.replace(/^_/, '.'))
}
// 把 src 文件拷贝到 dest
fs.copyFileSync(src, dest)
}
export default renderTemplate
# deepMerge
const isObject = (val) => val && typeof val === 'object'
// 去重
const mergeArrayWithDedupe = (a, b) => Array.from(new Set([...a, ...b]))
/**
* Recursively merge the content of the new object to the existing one
* @param {Object} target the existing object
* @param {Object} obj the new object
*/
function deepMerge(target, obj) {
for (const key of Object.keys(obj)) {
const oldVal = target[key]
const newVal = obj[key]
if (Array.isArray(oldVal) && Array.isArray(newVal)) {
// 去重合并
target[key] = mergeArrayWithDedupe(oldVal, newVal)
} else if (isObject(oldVal) && isObject(newVal)) {
// 如果是对象则递归调用
target[key] = deepMerge(oldVal, newVal)
} else {
// 如果都不是,则直接用新值覆盖
target[key] = newVal
}
}
return target
}
# sortDependencies
根据 depTypes 中数组的顺序将 json文件的依赖排序。
function sortDependencies(packageJson) {
const sorted = {}
const depTypes = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
for (const depType of depTypes) {
if (packageJson[depType]) {
sorted[depType] = {}
Object.keys(packageJson[depType])
.sort()
.forEach((name) => {
sorted[depType][name] = packageJson[depType][name]
})
}
}
return {
...packageJson,
...sorted
}
}
# test
关于测试
"scripts": {
"snapshot": "node snapshot.js",
"pretest": "run-s build snapshot",
"test": "node test.js",
},
# test.js 文件
借助 子线程 对 生成在 palyground 文件夹下面的 with-tests的目录,使用 如下两个命令进行 test和 e2e的测试。
pnpm test:unit:ci
pnpm test:e2e:ci
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { spawnSync } from 'child_process'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const playgroundDir = path.resolve(__dirname, './playground/')
for (const projectName of fs.readdirSync(playgroundDir)) {
if (projectName.endsWith('with-tests')) {
console.log(`Running unit tests in ${projectName}`)
const unitTestResult = spawnSync('pnpm', ['test:unit:ci'], {
cwd: path.resolve(playgroundDir, projectName),
stdio: 'inherit',
shell: true
})
if (unitTestResult.status !== 0) {
throw new Error(`Unit tests failed in ${projectName}`)
}
console.log(`Running e2e tests in ${projectName}`)
const e2eTestResult = spawnSync('pnpm', ['test:e2e:ci'], {
cwd: path.resolve(playgroundDir, projectName),
stdio: 'inherit',
shell: true
})
if (e2eTestResult.status !== 0) {
throw new Error(`E2E tests failed in ${projectName}`)
}
}
}
# snapshot.js
根据flags生成对应的项目快照,便于测试
import { spawnSync } from 'child_process'
import path from 'path'
const __dirname = path
.dirname(new URL(import.meta.url).pathname)
.substring(process.platform === 'win32' ? 1 : 0)
const bin = path.resolve(__dirname, './outfile.cjs')
const playgroundDir = path.resolve(__dirname, './playground/')
function createProjectWithFeatureFlags(flags) {
const projectName = flags.join('-')
console.log(`Creating project ${projectName}`)
const { status } = spawnSync(
'node',
[bin, projectName, ...flags.map((flag) => `--${flag}`), '--force'],
{
cwd: playgroundDir,
stdio: ['pipe', 'pipe', 'inherit']
}
)
if (status !== 0) {
process.exit(status)
}
}
const featureFlags = ['typescript', 'jsx', 'router', 'vuex', 'with-tests']
// The following code & comments are generated by GitHub CoPilot.
function fullCombination(arr) {
const combinations = []
// for an array of 5 elements, there are 2^5 - 1= 31 combinations
// (excluding the empty combination)
// equivalent to the following:
// [0, 0, 0, 0, 1] ... [1, 1, 1, 1, 1]
// We can represent the combinations as a binary number
// where each digit represents a flag
// and the number is the index of the flag
// e.g.
// [0, 0, 0, 0, 1] = 0b0001
// [1, 1, 1, 1, 1] = 0b1111
// Note we need to exclude the empty comination in our case
for (let i = 1; i < 1 << arr.length; i++) {
const combination = []
for (let j = 0; j < arr.length; j++) {
if (i & (1 << j)) {
combination.push(arr[j])
}
}
combinations.push(combination)
}
return combinations
}
const flagCombinations = fullCombination(featureFlags)
flagCombinations.push(['default'])
for (const flags of flagCombinations) {
createProjectWithFeatureFlags(flags)
}
# 收获
这一次的源码阅读的收获还是挺多的。
- 对一些文件,文件夹的操作有了基本的参考面,了解了一些nodejs中对fs文件操作的API
- 关于 minist,promtps有了一定的了解
- 了解到了新的pnpm包管理器。。
- 项目快照的生成和对应测试用例的编写。