- 本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
- 这是源码共读的第9期,链接:Vue 团队公开快如闪电的全新脚手架工具 create-vue,未来将替代 Vue-CLI,才300余行代码,学它! - 掘金 (juejin.cn)
预览知识
1、指令交互
npm地址:npm.im/prompts
prompts的指令实例:
prompts的参数为两种:1、单个的指令是传递一个对象。2、多个指令传递的是一个数组。3、动态的数组
传递的是一个数组的话,那么他会进行链式的调用,按照书写的顺序来执行。
链式的书写方法:
运行结果:
对应的属性:
| Param | Type | Description |
|---|---|---|
| type | string、function | 输入的格式 |
| name | string、function | 最后生成的prompts对象的属性 |
| message | string、function | 提问的信息 |
| initial | string、function、asyncfunction | 初始化的值 |
| format | function、asyncfunction | 对接收到的值进行格式化 |
2、解析指令
minimist的基础使用:
minimist的两个参数
1、解析的指令 2、配置项
其中opts的选项支持分别为:
- opt.string:一个字符串或者一组字符串。表示这些选项中的值将会被解析成字符串。
- opt.boolean:一个布尔值、一个字符串、一组字符串。当值为布尔值的时候,如果值为true那么选项中将命令行参数中没有值的选项解析为布尔值
true,如果值为false那么将解析器将所有参数都解析为字符串。当是一个字符串或者一组字符串的话,只有对应的参数将会被解析成布尔值。 - opt.alias:一个别名的映射。一个选项可以映射到不同的选项别名。
- opt.default:给对应的选项设置默认值。
- opt.unknown:一个函数。参数为未设定的选项名,如果函数返回值为true,那么该参数将会被添加到解析结果当中,否则不加
测试代码:
function processUnknown(command) {
console.log(command);
return false;
}
var argv = require('minimist')(process.argv.slice(2), {
boolean: ['hello', 's'],
alias: { s: ['isStudent', 'isOk'] },
default: { height: 100 },
// stopEarly: true,
// '--': true,
unknown: processUnknown
});
console.dir(argv);
node index.js --hello false cc --no-world -s false aa -a 100
cc
--no-world
aa
-a
{ _: [],
hello: false,
s: false,
isStudent: false,
isOk: false,
height: 100 }
源码的运行流程
- 用户选择模板,即可以交互的部分。
- 根据用户的输入,确定输出的目标路径。
- 判断是否存在目标路径,如果存在进行情况文件夹,不存在则创建文件夹。
- 根据用户的选择进行模板读取,进行模板的渲染。(源码当中最复杂的部分)
- 执行完毕,打印完安装完成的信息。
源码的主流程init函数的拆分
1、解析命令行参数
// 当前node.js的执行目录
const cwd = process.cwd()
// 解析命令
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
})
2、如果设置了 feature flags 跳过 prompts 询问
// 这种写法方便代码测试等。直接跳过交互式询问,同时也可以省时间。
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
3、指令交互部分
亮点:
- 为用户输入做了一整个的错误处理,防止出现错误无法知晓
指令交互大体代码
let result = {}
try {
result = await prompts(
[
{
type: targetDir ? null : 'text',
name: 'projectName',
message: reset('Project name:'),
initial: defaultTargetDir,
onState: state => {
targetDir = formatTargetDir(state.value) || defaultTargetDir
}
}
],
// 配置项
{
onCancel: () => {
throw new Error(red('X') + 'Operation cancelled')
}
}
)
} catch (cancelled) {
console.log(cancelled.message)
return
}
具体代码:
let result = {}
try {
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)
}
// 保存用户的输入指令
const {
packageName = toValidPackageName(defaultProjectName),
shouldOverwrite,
needsJsx = argv.jsx,
needsTypeScript = argv.typescript,
needsRouter = argv.router,
needsVuex = argv.vuex,
needsTests = argv.tests
} = result
4、重写已有目录/或者创建不存在的目录
function emptyDir(dir) {
// postOrderDirectoryTraverse(
// dir,
// (dir) => fs.rmdirSync(dir),
// (file) => fs.unlinkSync(file)
// )
// 以下代码为个人重写代码
// 读取dir文件夹下的所有文件
let files = fs.readdirSync(dir)
files.forEach(async (file) => {
// 当前file的路径
const currPath = path.resolve(dir, file)
// 获取当前的file的信息
const stat = fs.statSync(currPath)
// 判断当前文件是文件夹还是文件
if (stat.isDirectory()) {
// 清空当前文件夹
emptyDir(currPath)
// 删除当前文件夹
fs.rmdirSync(currPath)
} else {
// 删除当前文件
fs.unlinkSync(currPath)
}
})
}
// 根据用户输入生成目标路径
const root = path.join(cwd, targetDir)
if(shouldOverwrite){
// 清空文件夹
emptyDir(root)
}else{
// 创建目录
fs.mkdirSync(root)
}
5、根据模板文件生成初始化项目所需要的文件
// 脚手架项目目录
console.log(`\nScaffolding project in ${root}...`)
// 生成package.json文件
const pkg = {name:packageName,version:'0.0.0'}
fs.wirteFileSync(path.resolve(root,'package.json'),JSON.stringfy(pkg,null,2))
// 找到模板文件
const templateroot = path.resolve(__dirname,'template')
// 定义渲染函数
const render = function render(templateName){
const templateDir = path.resolve(templateroot,templateName)
// 根据我们的某一个模板渲染到我们的目标路径
renderTemplate(templateDir,root)
}
// 渲染基础模板
render('base')
// 根据用户输入添加配置
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')
}
6、生成代码模板
const codeTemplate =
(needsTypeScript ? 'typescript-' : '') +
(needsRouter ? 'router' : 'default')
render(`code/${codeTemplate}`)
// 渲染入口文件
if (needsVuex && needsRouter) {
render('entry/vuex-and-router')
} else if (needsVuex) {
render('entry/vuex')
} else if (needsRouter) {
render('entry/router')
} else {
render('entry/default')
}
7、如果配置了ts对js文件进行重命名
if (needsTypeScript) {
// 重命名所有的.js文件为.ts文件
// 重命名jsconfig.json为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'))
}
}
)
// 以下是我个人对上面这段代码的一个重写
function renameDirToTs(root){
// 读取root目录下的所有文件
let files = fs.readdirSync(root)
// 遍历
files.forEach(file=>{
// 获取当前路径
const currPath = path.resolve(root,file)
// 获取当前文件信息
const stat = fs.statSync(currPath)
// 对这个文件进行判断
if(stat.isDirectory()){
// 文件夹
renameDirToTs(currPath)
}else{
// 文件
if(currPath.endsWith('.js')){
fs.renameSync(currPath,fs.replace(/\.js$/, '.ts'))
}else if(path.basename(currPath) === 'jsconfig.json'){
fs.renameSync(currPath, filepath.replace(/jsconfig\.json$/, 'tsconfig.json'))
}
}
})
}
renameDirToTs(root)
8、根据不同的npm、yarn、pnpm生成README.md文件,给出运行项目提示
// 获取包管理器是哪一个通过process.env.npm_execpath去匹配
const packageManager = /pnpm/.test(process.env.npm_execpath)?'pnpm':/yarn/.test(process.env.npm_execpath)
?'yarn':'npm'
工具函数文件
1、directoryTraverse.js
它提供了两个函数:
- preOrderDirectoryTraverse:
- 参数:路径,对文件夹处理的回调函数,对文件处理的回调函数
- 作用:由于它是前序遍历,因此它主要是在进入节点前做一些事情
export function preOrderDirectoryTraverse(dir, dirCallback, fileCallback) {
// 遍历dir文件夹下的所有文件
for (const filename of fs.readdirSync(dir)) {
// 获取当前的文件路径
const fullpath = path.resolve(dir, filename)
// 获取当前文件信息,判断是否为文件夹
if (fs.lstatSync(fullpath).isDirectory()) {
// 对当前文件夹做处理
dirCallback(fullpath)
// 再递归处理
if (fs.existsSync(fullpath)) {
preOrderDirectoryTraverse(fullpath, dirCallback, fileCallback)
}
continue
}
fileCallback(fullpath)
}
}
- postOrderDirectoryTraverse
- 参数:路径,对文件夹处理的回调函数,对文件处理的回调函数
- 作用:由于它是后序遍历,因此它主要是在离开节点时做一些事情
// 清空文件夹就是用的这个,原因是因为删除文件夹必须得把里面的文件清空后才能进行删除操作,因此删除操作应该是后序遍历的位置
export function postOrderDirectoryTraverse(dir, dirCallback, fileCallback) {
for (const filename of fs.readdirSync(dir)) {
const fullpath = path.resolve(dir, filename)
if (fs.lstatSync(fullpath).isDirectory()) {
postOrderDirectoryTraverse(fullpath, dirCallback, fileCallback)
dirCallback(fullpath)
continue
}
fileCallback(fullpath)
}
}
2、renderTemplate.js
这个文件只提供renderTemplate函数来渲染,主要的作用有两点: 1、对文件进行拷贝,拷贝至目标的目录 2、对package.json文件进行配置合并的操作
// src为模板路径,dest为目标路径
function renderTemplate(src, dest) {
// 获取当前文件的信息
const stats = fs.statSync(src)
// 它是文件夹
if (stats.isDirectory()) {
// 通过recursive配置项来配置递归式创建文件夹
fs.mkdirSync(dest, { recursive: true })
// 遍历递归处理下一层
for (const file of fs.readdirSync(src)) {
renderTemplate(path.resolve(src, file), path.resolve(dest, file))
}
return
}
// 获取文件名
const filename = path.basename(src)
if (filename === 'package.json' && fs.existsSync(dest)) {
// 对package.json文件进行合并操作
// 对目标的package.json文件进行读取数据,转为字符串
const existing = JSON.parse(fs.readFileSync(dest))
// 对新的的package.json文件进行读取数据,转为字符串
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(/^_/, '.'))
}
// 对文件进行拷贝
fs.copyFileSync(src, dest)
}
3、deepMerge.js
由于在template文件夹中,已经将不同情况下的文件进行分类出来,只写上了独一无二的配置项,因此在deepMerge当中不会发生重叠的情况,只用考虑它们是什么,所以只有三种情况:
- 配置项都是对象
- 配置项都是数组
- 配置项不存在
因此对于数组的情况下总结对他进行合并,如果是对象的话进行遍历,单个不存在就赋新值
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)
} else if (isObject(oldVal) && isObject(newVal)) {
target[key] = deepMerge(oldVal, newVal)
} else {
target[key] = newVal
}
}
return target
}
export default deepMerge
4、sortDependencies.js
这个文件主要的作用就是对刚刚合并完的package.json进行排序,将信息按一定顺序排布
export default function sortDependencies(packageJson) {
// 排序好的对象
const sorted = {}
// 需要进行排序的属性
const depTypes = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
for(const depType of depTypes){
// 如果packageJson存在需要排序的属性那么就将它进行排序
if(packageJson[depType]){
sorted[depType] = {}
// 按键进行排序
Object.keys(packageJson[depType]).sort().forEach((name)=>{
sorted[depType][name] = packageJson[depType][name]
})
}
}
return {
...packageJson,
...sorted
}
}
总结与收获
总结
为什么create-vue快?
快如闪电的原因在于依赖的很少。很多都是自己来实现。如:Vue-CLI中 vue create vue-project 命令是用官方的npm包validate-npm-package-name,删除文件夹一般都是使用 rimraf。而 create-vue 是自己实现emptyDir和isValidPackageName。
为什么有的脚手架采用download-git-repo,而creat-vite不采用,反而采用文件读写方式呢?
| 优势 | 缺点 | |
|---|---|---|
| download-git-repo | 1. 方便易用:download-git-repo是一个简单易用的工具,可以快速地从Git存储库中下载并克隆模板。 2. 克隆完整的Git存储库:使用download-git-repo,你可以完整地克隆整个Git存储库,包括分支、提交历史和其他元数据。 3. 支持远程存储库:download-git-repo可以从远程Git存储库中克隆模板,这意味着你可以从GitHub、GitLab或其他Git托管平台中获取模板。 | 1. 依赖外部工具:download-git-repo依赖于Git工具,因此在使用之前,你需要确保你的系统已经安装并配置了Git。这可能增加了一些额外的设置和配置的复杂性。2. 无法选择性克隆:使用download-git-repo,你只能克隆整个存储库,而无法选择性地只克隆特定的文件或目录。这可能会导致不必要的下载和占用磁盘空间。 |
| 文件读写 | 1. 灵活性:使用文件读写的方法,你可以根据需要选择性地下载和保存模板中的文件或目录。你可以根据自己的需求,只保存所需的文件,而不必下载整个存储库。2. 无需外部依赖:文件读写方法不依赖于外部工具或库,因此不需要安装和配置Git或其他额外的软件。 | 1. 需要手动处理:相比于download-git-repo,使用文件读写的方法需要你手动处理下载和保存文件的逻辑。你需要编写代码来从远程服务器下载文件,并将它们保存到本地。2. 无法克隆完整的Git存储库:使用文件读写的方法,你只能下载模板中的特定文件或目录,而无法克隆完整的Git存储库,包括提交历史、分支等其他Git元数据。 |
收获
通过此次源码阅读,我明白了基于vite的脚手架搭建,完整的了解脚手架的代码流程,并且学习了代码的调试技巧,充分的理解了node的对文件的递归操作。