- 本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
- 这是源码共读的第9期,链接:
juejin.cn
一、目录结构:
index.ts
是整个 CLI 的打包入口,主文件。utils
工具函数。template
Vue 项目模板文件。playground
利用 create-vue 生成的项目的快照结果。scripts
包含了一些脚本,如测试、快照、预发布、构建。
二、create-vue 执行流程
- 使用
prompts
询问用户一系列 Yes/No 的问题,是否包含以下特性:TS, JSX, router, vuex, cypress。以及是否重写覆盖已有的文件夹。 - 在目标目录写入包含包名和版本号的
package.json
文件。 - 根据模板创建目标文件夹,调用
render
函数,先根据base
模板创建一个基础的项目,再根据用户选择的特性,将特性模板与基础项目合并。 - 支持
TS
特性的话,把所有js
后缀改为ts
后缀。将jsconfig.json
重命名为tsconfig.json
。 - 判断包管理器,生成
README.md
,提示用户项目生成成功并展示提示消息
三、主要逻辑
主要分析的 index.ts 中的源码逻辑
3.1 支持命令行参数
支持类似 npm create vue@latest --vuex --ts --jsx
这种命令行参数。这里使用了 minimist
库来解析命令行参数。
// possible options:
// --default
// --typescript / --ts
// --jsx
// --router / --vue-router
// --pinia
// --with-tests / --tests (equals to `--vitest --cypress`)
// --vitest
// --cypress
// --nightwatch
// --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
})
// 如果所有的 flag 都设置了,直接跳过提问阶段
const isFeatureFlagsUsed =
typeof (
argv.default ??
argv.ts ??
argv.jsx ??
argv.router ??
argv.pinia ??
argv.tests ??
argv.vitest ??
argv.cypress ??
argv.nightwatch ??
argv.playwright ??
argv.eslint
) === 'boolean'
3.2 交互式提问
通过交互式提问的方式,配置生成项目所包含的特性,这里借助了 prompts
库来收集交互结果。
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 Nightwatch 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: 'packageName',
// isValidPackageName:判断包名合法性
type: () => (isValidPackageName(targetDir) ? null : 'text'),
message: 'Package name:',
// toValidPackageName:合法化包名
initial: () => toValidPackageName(targetDir),
validate: (dir) => isValidPackageName(dir) || 'Invalid package.json name'
},
{
name: 'needsTypeScript',
// 如果使用了 flag,直接 type 函数返回 null,就不会提问了
type: () => (isFeatureFlagsUsed ? null : 'toggle'),
message: 'Add TypeScript?',
initial: false,
active: 'Yes',
inactive: 'No'
},
...
],
{
onCancel: () => {
throw new Error(red('✖') + ' Operation cancelled')
}
}
)
} catch (cancelled) {
console.log(cancelled.message)
process.exit(1)
}
3.3 搭建项目文件夹
- 创建文件夹
- 写入package.json文件
// 当前工作目录
const cwd = process.cwd()
// targetDir:项目文件夹名称
const root = path.join(cwd, targetDir)
// shouldOverwrite:是否重写文件夹
if (fs.existsSync(root) && shouldOverwrite) {
emptyDir(root)
} else if (!fs.existsSync(root)) {
fs.mkdirSync(root)
}
console.log(`\nScaffolding project in ${root}...`)
// 写入 package.json 文件
const pkg = { name: packageName, version: '0.0.0' }
fs.writeFileSync(path.resolve(root, 'package.json'), JSON.stringify(pkg, null, 2))
3.4 render 函数
// 所有模板位于 template 文件夹
const templateRoot = path.resolve(__dirname, 'template')
const callbacks = []
// 使用 renderTemplate 将 templateDir 下的内容尝试生成到 root 中。
// const root = path.join(cwd, targetDir)
const render = function render(templateName) {
const templateDir = path.resolve(templateRoot, templateName)
renderTemplate(templateDir, root, callbacks)
}
// Render base template
// 写入基础模板
render('base')
// Add configs.
// 写入其他配置模板
if (needsJsx) {
render('config/jsx')
}
....
template/base
是一个基础模板,它包括了 .vscode
、index.html
、vite.config.js
等这些前端项目基础文件。文件目录结构如下:
在模板文件夹里存在 _
前缀的文件,是为了避免影响一些 CLI 工具和编辑器的行为,这些文件在 render
的过程中会被重命名成 .
前缀。
3.5 对TS支持的特殊处理
主要做了两个工作:
- 检查现有的 js 文件,如果已经有 ts 文件,就删除 js 文件;否则,将
.js
文件后缀改为.ts
后缀 - 移除
jsconfig.json
,使用tsconfig.json
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') && !FILES_TO_FILTER.includes(path.basename(filepath))) {
const tsFilePath = filepath.replace(/\.js$/, '.ts')
// 如果存在同名 ts 文件,就删除原来的 js 文件,否则重命名后缀为 ts
if (fs.existsSync(tsFilePath)) {
fs.unlinkSync(filepath)
} else {
fs.renameSync(filepath, tsFilePath)
}
// 如果文件名是 jsconfig.json,就删除
} else if (path.basename(filepath) === 'jsconfig.json') {
fs.unlinkSync(filepath)
}
}
)
// Rename entry in `index.html`
// index.html 入口文件中引用了 main.js 文件, 也需要改成 ts 格式
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
// 如果不需要 ts 支持,就删掉所有 ts 文件
preOrderDirectoryTraverse(
root,
() => {},
(filepath) => {
if (filepath.endsWith('.ts')) {
fs.unlinkSync(filepath)
}
}
)
}
3.6 根据包管理器生成 Readme 文件和命令行提示
// 查询本地用户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,
needsNightwatch,
needsPlaywright,
needsNightwatchCT,
needsCypressCT,
needsEslint
})
)
console.log(`\nDone. Now run:\n`)
if (root !== cwd) {
// 从 cwd 到 root 的相对路径
const cdProjectName = path.relative(cwd, root)
// 使用 kolorist 库美化命令行输出
console.log(
` ${bold(green(`cd ${cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName}`))}`
)
}
console.log(` ${bold(green(getCommand(packageManager, 'install')))}`)
if (needsPrettier) {
console.log(` ${bold(green(getCommand(packageManager, 'format')))}`)
}
console.log(` ${bold(green(getCommand(packageManager, 'dev')))}`)
console.log()
四、主要工具函数
在了解了 index.ts
中的主要代码逻辑之后,已经可以对整个 create-vue
脚手架的工作原理有一定了解。接下来,对一些主要的工具函数进行分析。
directoryTraverse
在源码中使用两种文件目录递归方式,一种是 preOrderDirectoryTraverse
,一种是 postOrderDirectoryTraverse
。这两个函数都位于util/directoryTraverse.ts
中,两者是类似,唯一的区别是在当前路径是文件夹时,先进入下一层文件夹,还是先调用文件夹处理函数 dirCallback
,本质上就是多叉树的先序遍历和后序遍历。
// directoryTraverse.ts
// 先序遍历
export function preOrderDirectoryTraverse(dir, dirCallback, fileCallback) {
// fs.readdirSync() 返回一个包含目录中的所有文件名的数组
for (const filename of fs.readdirSync(dir)) {
// 跳过 .git 文件
if (filename === '.git') {
continue
}
const fullpath = path.resolve(dir, filename)
// fs.lstatSync(fullpath)返回一个stats对象 描述文件或设备信息
// 如果是文件夹就进行递归,否则直接调用fileCallback
if (fs.lstatSync(fullpath).isDirectory()) {
dirCallback(fullpath)
// dirCallback操作可能会删除目录,所以需要判断目录是否存在
if (fs.existsSync(fullpath)) {
preOrderDirectoryTraverse(fullpath, dirCallback, fileCallback)
}
continue
}
fileCallback(fullpath)
}
}
// 后序遍历
export function postOrderDirectoryTraverse(dir, dirCallback, fileCallback) {
for (const filename of fs.readdirSync(dir)) {
if (filename === '.git') {
continue
}
const fullpath = path.resolve(dir, filename)
if (fs.lstatSync(fullpath).isDirectory()) {
postOrderDirectoryTraverse(fullpath, dirCallback, fileCallback)
dirCallback(fullpath)
continue
}
// 做文件进行操作
fileCallback(fullpath)
}
}
renderTemplate
在 create-vue
render
模板的时候,本质上是调用了一个 renderTemplate
函数。
renderTemplate
主要做了以下工作:
- 将 src 的目录或文件递归地拷贝到 dest 下
- 以 _ 命名的文件会替换为以 . 命名
- package.json 如果已存在 dest 中,则对其内容进行合并
// renderTemplate.ts
function renderTemplate(src, dest, callbacks) {
// 获取src的文件信息
const stats = fs.statSync(src)
// src 是文件夹时,递归渲染子目录
if (stats.isDirectory()) {
// path.basename() 从路径中解析出文件名
if (path.basename(src) === 'node_modules') {
return
}
// fs.mkdirSync(dest, { recursive: true }) 可以创建深层文件夹,例如 /a/b/c, 即使不存在/a 或 /a/b
fs.mkdirSync(dest, { recursive: true })
// 递归子目录
for (const file of fs.readdirSync(src)) {
renderTemplate(path.resolve(src, file), path.resolve(dest, file), callbacks)
}
return
}
// src 是一个单文件
const filename = path.basename(src)
if (filename === 'package.json' && fs.existsSync(dest)) {
// 合并 package.json,并对依赖进行排序
const existing = JSON.parse(fs.readFileSync(dest, 'utf8'))
const newPackage = JSON.parse(fs.readFileSync(src, 'utf8'))
const pkg = sortDependencies(deepMerge(existing, newPackage))
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)
}
deepMerge 和 sortDependencies
在合并 package.json
时,使用了两个函数 deepMerge
和 sortDependencies
。
deepMerge
递归合并新的 json 文件到已有文件中。主要思路就是两个值都是对象时继续递归,碰到两个数组的时候就进行一个浅拷贝合并,否则就直接赋值。
const isObject = (val) => val && typeof val === 'object'
const mergeArrayWithDedupe = (a, b) => Array.from(new Set([...a, ...b]))
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)
// 当两个val都是对象时,进行递归处理
} else if (isObject(oldVal) && isObject(newVal)) {
target[key] = deepMerge(oldVal, newVal)
} else {
target[key] = newVal
}
}
return target
}
在 deepMerge
之后还需要对 package.json
中的依赖字段按照插入序排列
export default 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
}
}
总结
create-vue 代码简洁,抛弃 Webpack 全面拥抱 vite 之后轻松了许多,开发体验提升了不少。通过学习 create-vue 源码,可以了解脚手架是如何工作的,对前端工程化也有更深的理解,在不了解脚手架之前完全不知道脚手架是如何运作的,像是一个未知的黑盒,恐惧它不如打开看看,没什么大不了的。