首页IT科技代替Vue Cli的全新脚手架工具create vue示例解析

代替Vue Cli的全新脚手架工具create vue示例解析

时间2025-05-05 16:06:04分类IT科技浏览4420
导读:前言 美国时间 2021 年 10 月 7 日早晨,Vue 团队等主要贡献者举办了一个 Vue Contributor Days 在线会议,蒋豪群(知乎胖茶,Vue.js 官方团队成员,Vue-CLI 核心开发),在会上公开了create-vue,一个全新的脚手架工具。...

前言

美国时间 2021 年 10 月 7 日早晨           ,Vue 团队等主要贡献者举办了一个 Vue Contributor Days 在线会议                  ,蒋豪群(知乎胖茶      ,Vue.js 官方团队成员           ,Vue-CLI 核心开发)                 ,在会上公开了create-vue      ,一个全新的脚手架工具           。

create-vue 使用 npm init vue 一行命令就能快速的创建基于Vite的Vue3项目

npm init

$ npm init vue

以前我们初始化Vue-Cli项目时太多通过全局的形式安装, 然后通过vue create project-name命令进行项目安装      ,为什么npm init 也可以可以直接初始化一个项目且不需要全局安装?

本质是 npx 命令的语法糖                 ,它在调用时是会转换为npx 命令

npm init vue@next -> npx create-vue@next npm init @harexs -> npx @harexs/create npm init @harexs/test -> npx @harexs/create-test

看完这三个例子应该就明白了 npm init 的作用了

npx

从本地或者远程npm包运行命令

npx 就是一种调用npm包的命令            ,如果没提供-c或者--call命令则默认从我们指定的包中      ,查找package.json中的 bin字段                 ,从而确定要执行的文件

{ "name": "create-vue", "version": "3.3.4", "description": "An easy way to start a Vue project", "type": "module", "bin": { "create-vue": "outfile.cjs" //关键 } }

那npm init vue 完整的解析就是 本地或者远程寻找 create-vue 这个包            ,然后查找package.json中 bin字段值的可执行文件,最终就是运行了outfile.cjs这个文件.

源码

这里使用川哥的create-vue-analysis仓库                 ,仓库的版本便于我们学习其思路和实现                  ,对应的是3.0.0-beta.6版本                  。 最新版本已经到了3.3.4, 但核心功能以及实现思路是不变的.

仓库地址 create-vue-analysis

主流程入口

//index.js async function init() { /// } init().catch((e) => { console.error(e) })

先不看其他部分, 先关注入口这里 就是 自调用了异步函数 init

获取参数

const cwd = process.cwd() //获取当前运行环境项目目录 //process.argv.slice(2) 用来获取 npx create-vue 后面传入的参数 值为数组 //minimist 用来格式化获取传入的参数 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 }) //通过minimist获取的argv结果是个对象           ,通过对象属性去判断 是否有传入参数 const isFeatureFlagsUsed = typeof (argv.default || argv.ts || argv.jsx || argv.router || argv.vuex || argv.tests) === boolean //argv._ 这个_ 属性获取的是 没有-或者--开头的参数 //比如 npm init create-vue xxx --a 那么argv就是 {_:[xxx],a:true} let targetDir = argv._[0] // argv._[0] 假如对应{_:[xxx],a:true} 就是 xxx //给一会的选项用的 默认项目名称 defaultProjectName 默认取targetDir const defaultProjectName = !targetDir ? vue-project : targetDir const forceOverwrite = argv.force

接着是第二部分                  ,主要就是获取 运行目录 以及 判断 命令调用时 有没有传入指定参数

对话选项

try { result = await prompts( [ { //name 参数就是一会要收集的对应变量 name: projectName, //判断targetDir 有没有值 有值的话 就是null null会跳过这个对话 type: targetDir ? null : text, message: Project name:, initial: defaultProjectName, //默认结果值 获取参数部分已经说过这个变量了 //onState 完成回调      ,让targetDir 取 用户输入的内容 没输入直接回车的话 取defaultProjectName onState: (state) => (targetDir = String(state.value).trim() || defaultProjectName) }, { name: shouldOverwrite, //canSafelyOverwrite 判断是否是空目录并可以写入 否则判断有没有参数--force 目录 // 有一个条件有效就为null 就跳过写入对话           , 否则为confirm 确认框 y/n 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 是整个对象 //如果 shouldOverwrite 阶段 type变为 confirm 并且还选了 no //那么这一阶段判断后就会直接退出 不再执行 抛出异常 type: (prev, values = {}) => { if (values.shouldOverwrite === false) { throw new Error(red() + Operation cancelled) } return null } }, { name: packageName, //正则验证 是否符合 package.json name 的值,不符合则让用户输入 type: () => (isValidPackageName(targetDir) ? null : text), message: Package name:, //没输入则取默认值 将targetDir 通过函数转换为符合packageName的格式 initial: () => toValidPackageName(targetDir), //校验函数      ,如果 用户输入的包名无法通过 则提示Invalid package.json name 重新输入 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 //toggle 和confirm 无异 isFeatureFlagsUsed 如果有指定某一参数 则跳过后面所有对话, 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) }

这一部分 使用了prompts这个库                 , 它提供了 命令行对话选项的能力            , 这里主要收集用户的选择以及输入      ,

默认值

//取出前面对话选项后的值                 , 如果没有的话 就取 argv上的默认值 const { packageName = toValidPackageName(defaultProjectName), shouldOverwrite, needsJsx = argv.jsx, needsTypeScript = argv.typescript, needsRouter = argv.router, needsVuex = argv.vuex, needsTests = argv.tests } = result //root为 命令运行位置 + targetDir 得到 项目路径 const root = path.join(cwd, targetDir) //如果之前判断的文件目录可写入 则执行一次emptyDir 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 } //往root目录 写入初始化 package.json文件 fs.writeFileSync(path.resolve(root, package.json), JSON.stringify(pkg, null, 2))

emptyDir函数

function emptyDir(dir) { postOrderDirectoryTraverse( dir, (dir) => fs.rmdirSync(dir), (file) => fs.unlinkSync(file) ) }

emptyDir 内部调用 postOrderDirectoryTraverse 函数            ,它来自utils下 我们接着看

export function postOrderDirectoryTraverse(dir, dirCallback, fileCallback) { //fs.readdirSync(dir) 返回一个数组 包含当前目录下的文件名 列表 for (const filename of fs.readdirSync(dir)) { //遍历列表 得到 文件的完整路径 const fullpath = path.resolve(dir, filename) if (fs.lstatSync(fullpath).isDirectory()) { // 如果这个文件 也是个目录 则递归继续遍历 //因为删除目录的话 必须要先删除所有文件 postOrderDirectoryTraverse(fullpath, dirCallback, fileCallback) //执行记dirCallback 回调 也就是fs.rmdirSync(dir) 移除目录 dirCallback(fullpath) continue } //否则调用第二个回调 就是移除文件 fileCallback(fullpath) } }

emptyDir 就是对目录 递归遍历,遇到目录就继续递归遍历然后删除目录                 ,文件就直接删除

模板写入

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) }

先看第一部分

// 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) //需要区分的是 templateDir取的是 对应当前执行文件环境中的文件地址 // root变量 path.join(cwd, targetDir) process.cwd() 也就是取的命令执行时的地址 //到时候对应的可能就是这样: //C:xxx/xxxx/npm-cache/_npx/xxxx/.bin/create-vue/template //D:/xxx/projectDir/vue-project const render = function render(templateName) { const templateDir = path.resolve(templateRoot, templateName) renderTemplate(templateDir, root) }

需要注意 __dirname 是不存在会报错的                  ,作者在注释也留有信息, 因为我们的项目环境是ESM,原先CJS的环境变量不能用了           , 所以我们要换成这种写法

import path from "node:path"; import url from "node:url"; const __filename = url.fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); console.log(__filename, __dirname);

node:xxx 这种写法是Node提供的                  , 我在 promiseify 文章中也有讲到过

接下里看 renderTemplate函数

./utils/renderTemplate.js

function renderTemplate(src, dest) { //src 是 npx拉取到create-vue本地缓存目录中的 模板目录地址 //dest 是 用户命令执行时的 项目地址 const stats = fs.statSync(src) //statSync 返回一个文件类对象 可以看下面的截图 if (stats.isDirectory()) { // if its a directory, render its subdirectories and files recusively //如果src 是一个目录 则 在对应dest 的位置创建一个目录 //recursive: true 允许递归创建目录 也就是允许 a/b/c 这种形式来创建 fs.mkdirSync(dest, { recursive: true }) for (const file of fs.readdirSync(src)) { //遍历src中所有文件      , 递归自身           ,传入的参数变为 src/file 也即是每个文件名 //第二个参数 对应的就是 dest/file renderTemplate(path.resolve(src, file), path.resolve(dest, file)) } return } //这一步就是循环遍历 将src中的每个文件包目录都写入到 我们声明的project(dest)项目中 //如果src不是一个目录 则来到这里 先取出文件名 const filename = path.basename(src) //判断文件名是否为 package.json 并且 dest 也对应存在这个文件 if (filename === package.json && fs.existsSync(dest)) { // merge instead of overwriting //读取两个package.json的内部 const existing = JSON.parse(fs.readFileSync(dest)) const newPackage = JSON.parse(fs.readFileSync(src)) //合并两个文件的内并 并重新排序 得到新的 package.json内容 const pkg = sortDependencies(deepMerge(existing, newPackage)) //重新写入到 dest下 fs.writeFileSync(dest, JSON.stringify(pkg, null, 2) + \n) return } if (filename.startsWith(_)) { // rename `_file` to `.file` //resolve 合并名字 //dirname 取目录名字 dest = path.resolve(path.dirname(dest), filename.replace(/^_/, .)) } //拷贝文件 fs.copyFileSync(src, dest) }

接下来解析合并以及排序package.json的工具函数

//判断是不是对象 const isObject = (val) => val && typeof val === object //合并数组值 通过new Set 去除重复项 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 } export default deepMerge

比较简单的深拷贝函数

export default function sortDependencies(packageJson) { // packageJson json的内容对象 // sorted排序字段 const sorted = {} //需要排序的类型 const depTypes = [dependencies, devDependencies, peerDependencies, optionalDependencies] for (const depType of depTypes) { //如果json中包含了 这个字段 if (packageJson[depType]) { //赋值sorted对应的 depType 为空对象 sorted[depType] = {} Object.keys(packageJson[depType]) //得到packageJson depType所有key的数组 .sort() //使用默认排序 .forEach((name) => { //遍历这个key的数组 然后将对应的值 重新赋值到sorted depType name sorted[depType][name] = packageJson[depType][name] }) } } //ES6 展开对象语法 重复的key 会被覆盖 达到排序的效果 return { ...packageJson, ...sorted } }

sortDependencies 核心就是通过声明指定字段数组 然后取出来给到新的对象                 ,然后通过ES6的展开语法 同样的key 新key覆盖旧key的应用实现了排序效果

又学到一招      ,展开语法真好使啊~

// 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) }

在看这一段      ,基本能明白主要是在做什么操作      。通过前面收集的变量进行render

if (needsTypeScript) { // rename all `.js` files to `.ts` // rename jsconfig.json to tsconfig.json //前面说过 preOrderDirectoryTraverse函数                 ,前面是通过遍历的形式 去移除文件和目录 //这里的调用只传入第二个参数就是针对非目录的文件 preOrderDirectoryTraverse( root, () => {}, (filepath) => { //看作者的注释也很好理解            ,遇到.js结尾文件 重写为ts if (filepath.endsWith(.js)) { fs.renameSync(filepath, filepath.replace(/\.js$/, .ts)) //遇到jsconfig.json 重写为 tsconfig.json } else if (path.basename(filepath) === jsconfig.json) { fs.renameSync(filepath, filepath.replace(/jsconfig\.json$/, tsconfig.json)) } } ) // Rename entry in `index.html //取到 index.html 文件路径 const indexHtmlPath = path.resolve(root, index.html) //通过utf-8 读取 文件内容 const indexHtmlContent = fs.readFileSync(indexHtmlPath, utf8) //将原本引用的 main.js 重写为 main.ts 这里也很好理解 fs.writeFileSync(indexHtmlPath, indexHtmlContent.replace(src/main.js, src/main.ts)) }
export function preOrderDirectoryTraverse(dir, dirCallback, fileCallback) { for (const filename of fs.readdirSync(dir)) { //遍历dir const fullpath = path.resolve(dir, filename) if (fs.lstatSync(fullpath).isDirectory()) { dirCallback(fullpath) //如果是目录 则 调用dirCallback // in case the dirCallback removes the directory entirely //执行完后再判断 目录是否还存在 再递归自身继续调用 if (fs.existsSync(fullpath)) { preOrderDirectoryTraverse(fullpath, dirCallback, fileCallback) } continue } fileCallback(fullpath) } }

preOrderDirectoryTraverse稍有不同      ,对于目录级的回调会先执行然后再判断目录是否还存在,在进行递归

if (!needsTests) { // All templates assumes the need of tests. // If the user doesnt need it: // rm -rf cypress **/__tests__/ preOrderDirectoryTraverse( root, (dirpath) => { //对于目录 得到目录名 const dirname = path.basename(dirpath) //如果目录名为cypress || __tests__ if (dirname === cypress || dirname === __tests__) { emptyDir(dirpath) //执行 清空目录操作 fs.rmdirSync(dirpath) //最后移除这个目录 } }, () => {} ) }
//通过npm_execpath来获取当前执行的包管理器绝对路径 //得到路径后 通过 关键 字符去匹配 const packageManager = /pnpm/.test(process.env.npm_execpath) ? pnpm : /yarn/.test(process.env.npm_execpath) ? yarn : npm // README generation //生产README generateReadme 也比较简单不展开讲它了 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()

简述

至此                 ,整个源码就解析完毕了            ,刚开始确实感觉很复杂,毕竟Node很多API都不是很熟悉                 , 然后就一一拆解开来对着文档慢慢看了                  ,读源码还是很需要耐心~

大体的流程:

收集用户指定的参数 以及 项目名 通过对话选项卡确定用户的配置 根据对话选项卡后用户的配置匹配模板目录下的文件,一一写入到项目文件夹中 再判断是否需要Ts/测试 对文件做修改 生成其他文件 流程结束

快照

项目中还有个snapshot.js文件           , 它主要是通过const featureFlags = [typescript, jsx, router, vuex, with-tests]组合生成 31种加上default共计 32种组合                  ,然后通过子线程命令spawnSync 调用 bin 然后循环把 我们组合好的 参数传给它执行      ,也就是相当于执行了npm init vue这一步操作并传入组合好的参数

最后生成不同的模板在 playground目录中

关于这个命令: spawnSync

总结

在写完本文的时候           ,把源码大概梳理了2-3遍                 , 读源码很需要耐心      , 遇到不懂的API还要翻文档      , 但是等你完全弄明白里面的原理和实现思路之后                 ,就有一种油然而生的开心, 而且以后要开发类似的脚手架时 也可以有一定的思路和想法去实现它!

以上就是代替Vue Cli的全新脚手架工具create vue示例解析的详细内容            ,更多关于Vue Cli脚手架工具create vue的资料请关注本站其它相关文章!

声明:本站所有文章      ,如无特殊说明或标注                 ,均为本站原创发布           。任何个人或组织            ,在未征得本站同意时,禁止复制           、盗用                  、采集      、发布本站内容到任何网站           、书籍等各类媒体平台                 。如若本站内容侵犯了原著者的合法权益                 ,可联系我们进行处理      。

创心域SEO版权声明:以上内容作者已申请原创保护,未经允许不得转载,侵权必究!授权事宜、对本内容有异议或投诉,敬请联系网站管理员,我们将尽快回复您,谢谢合作!

展开全文READ MORE
掌握什么的技巧(2:掌握Const 与一二级指针结合应用)