当前位置 : 主页 > 网络编程 > JavaScript >

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

来源:互联网 收集:自由互联 发布时间:2023-02-01
目录 前言 npm init npx 源码 主流程入口 获取参数 对话选项 默认值 emptyDir函数 模板写入 简述 快照 总结 前言 美国时间 2021 年 10 月 7 日早晨,Vue 团队等主要贡献者举办了一个 Vue Contrib
目录
  • 前言
    • npm init
    • npx
  • 源码
    • 主流程入口
    • 获取参数
    • 对话选项
    • 默认值
    • emptyDir函数
    • 模板写入
    • 简述
  • 快照
    • 总结

      前言

      美国时间 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.jsonbin字段值的可执行文件,最终就是运行了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 it's 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 doesn't 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的资料请关注自由互联其它相关文章!

      上一篇:Nodejs Sequelize手册学习快速入门到应用
      下一篇:没有了
      网友评论