【源码】create-vue脚手架原理

1. 前言

美国时间 2021 年 10 月 7 日早晨,Vue 团队等主要贡献者举办了一个 Vue Contributor Days 在线会议,蒋豪群知乎胖茶Vue.js 官方团队成员,Vue-CLI 核心开发),在会上公开了create-vue,一个全新的脚手架工具。
An easy way to start a Vue project。一种简单的初始化vue项目的方式。

create-vue使用npm init vue@next一行命令,就能快如闪电般初始化好基于viteVue3项目。

2.try

npm init vue@next

在终端输入命令后 如下,直接就好了,简直是快如闪电:
在这里插入图片描述
run

cd vue3-project
npm install 
npm run dev

打开页面http://localhost:3000在这里插入图片描述

2.1 npm init

问题来了 为什么 npm init vue@next 可以直接初始化一个项目呢?

先来看看npm init 文档
用法

npm init [--force|-f|--yes|-y|--scope]
npm init <@scope> (same as `npx <@scope>/create`)
npm init [<@scope>/]<name> (same as `npx [<@scope>/]create-<name>`)

The init command is transformed to a corresponding npx operation as follows:

npm init foo -> npx create-foo
npm init @usr/foo -> npx @usr/create-foo
npm init @usr -> npx @usr/create

可以理解为

# 运行
npm init vue@next
# 相当于
npx create-vue@next

关于npx :

3. 源码阅读

git clone https://github.com/vuejs/create-vue.git
cd /create-vue
npm i

index.js

#!/usr/bin/env node
// @ts-check

// 文件模块
import fs from 'fs'
// 路径
import path from 'path'
// 命令行参数解析工具
import minimist from 'minimist'
// 轻量级,美观且用户友好的交互式提示
import prompts from 'prompts'
// 轻量级的使命令行输出带有色彩的工具 (与chalk相同)
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),//同步版本的 rmdir() 返回值为null 或 undefined则表示删除成功,否则将抛出异常。
    (file) => fs.unlinkSync(file)//同步版的 unlink() ,删除文件操作。
  )
}

async function init() {
  /**
   * process.cwd() 是当前执行node命令时候的文件夹地址-- 工作目录(保证了文件在不同的目录下执行时,路径始终不变)
   * _dirname 是被执行的js文件的地址 -- 文件所在目录
   */
  //返回当前node.js 进程执行时的工作目录

  const cwd = process.cwd()
  // possible options:
  // --default
  // --typescript / --ts
  // --jsx
  // --router / --vue-router
  // --pinia
  // --with-tests / --tests / --cypress
  // --force (for force overwriting)
  /**
   * process.argv 返回数组 包含启动node.js 进程时传入的命令行参数。
   * 第一个元素将是 原始值
   * 第二个元素 正在执行的JavaScript 文件的路径
   */
  // minimist的传参例子
  //  $ node example/parse.js -a beep -b boop
  //  { _: [], a: 'beep', b: 'boop' }
   
  //  $ node example/parse.js -x 3 -y 4 -n5 -abc --beep=boop foo bar baz
  //  { _: [ 'foo', 'bar', 'baz' ],
  //    x: 3,
  //    y: 4,
  //    n: 5,
  //    a: true,
  //    b: true,
  //    c: true,
  //    beep: 'boop' }

  /**
   * 解析命令行参数
   */
  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

  /**
   * 这种写法方便代码测试等。直接跳过交互式询问,同时也可以省时间。
   * 如果设置了 feature flags 跳过 prompts 询问
   */
  const isFeatureFlagsUsed =
    typeof (argv.default || argv.ts || argv.jsx || argv.router || argv.pinia || argv.tests) ===
    'boolean'
  // 生成目录
  let targetDir = argv._[0]
  // 默认vue-project
  const defaultProjectName = !targetDir ? 'vue-project' : targetDir
  // 强制重写文件夹,当同名文件夹存在时
  const forceOverwrite = argv.force


  /**
   * 交互式询问一些配置
   * 如上文npm init vue@next 初始化的图示
   */
  let result = {}

  try {
    // Prompts提示:
    // - Project name-项目名称:
    //   - whether to overwrite the existing directory or not? -是否覆盖现有目录?
    //   - enter a valid package name for package.json -为package.json输入有效的包名称
    // - Project language: JavaScript / TypeScript -项目语言:JavaScript/TypeScript
    // - Add JSX Support?-添加JSX支持?
    // - Install Vue Router for SPA development?-为SPA开发安装Vue路由器?
    // - Install Pinia for state management?-为状态管理安装Pinia?
    // - Add Cypress for testing?-添加Cypress进行测试?

    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: 'needsPinia',
          type: () => (isFeatureFlagsUsed ? null : 'toggle'),
          message: 'Add Pinia 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,
    needsPinia = argv.pinia,
    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}...`)

  //生成package.json 文件
  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')
  //传给一个模板名称,例如`base`,对应template/base 这个模板
  const render = function render(templateName) {
    //拿到真正的路径 templateDir 之后使用 renderTemplate 将 templateDir 下的内容尝试生成到 root 中,这里 root 就是之前用户输入指定的目标路径
    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 (needsPinia) {
    render('config/pinia')
  }
  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 (needsPinia && needsRouter) {
    render('entry/router-and-pinia')
  } else if (needsPinia) {
    render('entry/pinia')
  } 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` 将index.html 里的main.js  命名为main.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'))
  }
  
  //默认 所有模板都假定需要测试,不需要的时候  rm -rf cypress 、/__tests__/文件夹
  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 生成README.md文件,给出运行项目的提示
  // 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`. 无法判断命令是否由“pnpm init”调用。
  const packageManager = /pnpm/.test(process.env.npm_execpath)
    ? 'pnpm'
    : /yarn/.test(process.env.npm_execpath)
    ? 'yarn'
    : 'npm'

  // README generation 生成README.md
  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)
})

总体流程可以总结为如下:
1. 解析命令参数行
2.如果设置了 feature flags 就跳过 prompts 询问,如果没就询问用户一系列 Yes/No 的问题,看用户需要哪些 feature,包括 TS, JSX, router, vuex, cypress(上方截图的流程)
3.设置交互式询问配置
4.生成一些列文件夹目录 (代码生产模板,packge.json 文件, redme.md 文件等)
5.根据模板文件生成初始化项目所需文件 例如:js/ts/router/pinia(状态管理器),主要是使用了render函数
6.默认使用了cypress来作为自动化测试的工具,如果不需要后期可以删除
7.判断当前的包管理器是什么:pnpm/npm/yarn,生成对应的REDAME.md,方便后期执行install
8.判断当前生成的文件夹是否是当前执行命令的文件夹地址,如果不是作出一些相应提示,接着启动项目cd xx / npm install / npm run xx

4. render函数补充:

template/base 是一个最简单的所有结果都需要模板,它包括了 .vscode、index.html、vite.config.js 等这些基础性的东西。注意 vite 的理念和 Webpack 不一样,Webpackesbuild 这些都是以JS 为入口,但是 vite 是以 index.html为入口的,使用的时候需要转换一下思维。这个模板的目录结构如下:

.
├── _gitignore
├── index.html
├── package.json
├── public
│   └── favicon.ico
└── vite.config.js

注意里面有个 _gitignore 文件,使用 _ 开头是个惯例,因为以 . 开头的都是配置文件,会影响一些 CLI 工具和编辑器的行为,所以为了避免影响而使用 _,真正 render 的过程中需要重命名成 . 开头

我们主要看 renderTemplate 这个函数,位于 util/renderTemplate.js 中。

//接受收到传入的 真正路径,递归处理文件夹下的文件
function renderTemplate(src, dest) {
  const stats = fs.statSync(src)

  if (stats.isDirectory()) {
    // if it's a directory, render its subdirectories and files recursively
    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)
  //如果目标文件中的文件是是package.json 并且默认目标路径已经存在了。就需要merge两个 JSON 对象,然后将 dependencies, devDependencies, peerDependencies, optionalDependencies 这 4 个字段按照字母序从上到下排列好。
 if (filename === 'package.json' && fs.existsSync(dest)) {
    // merge instead of overwriting
    const existing = JSON.parse(fs.readFileSync(dest))
    const newPackage = JSON.parse(fs.readFileSync(src))
    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(/^_/, '.'))
  }

5. deepMerge 合并两个 object

如果都是对象的话就继续递归,递归到原始类型的时候就可以直接赋值来实现赋值了,而数组的话直接用解构赋值来一个浅拷贝就行了。

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
}

export default deepMerge

6.sortDependencies将对象按照 key 进行排序

sortDependencies 是将对象按照 key 进行排序,ES6 标准要求 object 对字符串类型的 key 按照插入序排列,对整数类型的 key 按照升序排列,因为依赖项都是 npm 包名,必然以字母开头,可以按照插入序保证其迭代的时候的顺序,从而使得解构赋值能够拿到正确的顺序。

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

7. 清空文件夹 递归删除文件

function emptyDir(dir) {
  postOrderDirectoryTraverse(
    dir,
    (dir) => fs.rmdirSync(dir),//同步版本的 rmdir() 返回值为null 或 undefined则表示删除成功,否则将抛出异常。
    (file) => fs.unlinkSync(file)//同步版的 unlink() ,删除文件操作。
  )
}


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

fs.rmdirSync只能删除空文件,非空会报错,此处为了减少包的饮用,直接实现了递归删除文件的功能
用的是多叉树深搜中的后序遍历,因为需要先删除子文件和子文件夹,才能保证当前文件夹为空。

8.总结

1.creat-vue@vue/cli快,原因在于相对依赖少,代码行数少
2.npm init xxx 相当于 npx creat-XX npm init
3.creat-vue完成了
1.创建默认文件vue-project,可以自定义输入文件名
2. 提供使用率比较高的库供用户选择并生成相应的模板
3. 完成项目创建,并提供运行提示
4.process.env.npm_config_user_agent动态取用户使用的包管理工具
包管理工具使用优先级 pnpm > yarn > npm

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值