likes
comments
collection
share

create-vue快速生成项目,到底是怎么做的

作者站长头像
站长
· 阅读数 14

前言

前段时间我们有去探索了一下vue-clicra的原理,生成项目的过程,他是基于webpack的,但是今天我们的主角是create-vue,他是基于vite的,为什么要使用vite而不是webpack呢?因为vitewebpack快。 我们使用vue-clivite快速生成一个项目分别耗时如下:

webpack

create-vue快速生成项目,到底是怎么做的

vite

create-vue快速生成项目,到底是怎么做的

就目前的图来看vite在构建、启动项目的时候会比webpack快10倍,这还是仅仅只是简单的项目,前端的项目复杂程度本来就是呈指数上升的,这时候vitewebpack可谓是天差地别。那么接下来我将带领大家一步一步去探索vite的各种优点。

准备工作

首先我们接着前言里面如何用vite去初始化一个Vue3的项目吧。 官方推荐使用

npm init vue@latest 这个命令将会去创建一个create-vue脚手架,实际的本质就是去下载create-vue这个包,然后执行index.ts文件。

create-vue快速生成项目,到底是怎么做的

create-vue快速生成项目,到底是怎么做的

前置导入模块

import * as fs from 'node:fs'
import * as path from 'node:path'
// fileURLToPath用来获取根路径的,源码里面没有,在下文path.resolve(__dirname, 'template') 那里会报错。
// 原因:因为在package.json里面定义的为ESM规范,而在index.ts文件里面,
出现了Common.js规范,两种规范下的实现是不同的,所以会报错:__dirname is not define
import { fileURLToPath } from 'url' 

import minimist from 'minimist' // 解析命令行参数
import prompts from 'prompts' // 命令行交互
import { red, green, bold } from 'kolorist' // 有色输出

// 在控制台打印的banner
// gradientBanner 内容为下面的a变量
// const defaultBanner = 'Vue.js - The Progressive JavaScript Framework'
import * as banners from './utils/banners'

import renderTemplate from './utils/renderTemplate'
import { postOrderDirectoryTraverse, preOrderDirectoryTraverse } from './utils/directoryTraverse'
import generateReadme from './utils/generateReadme'
import getCommand from './utils/getCommand'
import renderEslint from './utils/renderEslint'

create-vue快速生成项目,到底是怎么做的

工具函数

isValidPackageName

我们发现在cra、vue-cli中都是借助于validate-npm-package-name这个包实现的,这里面选择了重写这个库,省去了库的安装,读取等操作,进而提升速度。

function isValidPackageName(projectName) {
  return /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(projectName)
}

toValidPackageName

如果包名检查不合法就要把他变为合法的,比如toValidPackageName(myProject) => myproject,还会去空格等。

function toValidPackageName(projectName) {
  return projectName
    .trim()
    .toLowerCase()
    .replace(/\s+/g, '-') // 匹配空白字符到的第一个字符开始,直到匹配失败 eg:'lov e' => 'lov-e'
    .replace(/^[._]/, '') // 匹配 '.' '_' 为 ''
    .replace(/[^a-z0-9-~]+/g, '-') // 包含a-z、 0-9 - ~ 重复多个
}

create-vue快速生成项目,到底是怎么做的

canSkipEmptying

顾名思义,读取操作文件是否可以跳过

function canSkipEmptying(dir: string) {
  if (!fs.existsSync(dir)) { // fs.exists() 如果路径存在,则返回 `true`,否则返回 `false`。
    return true
  }

  const files = fs.readdirSync(dir) // 返回一个不包括 `'.'` 和 `'..'` 的文件名的数组。
  if (files.length === 0) {
    return true
  }
  if (files.length === 1 && files[0] === '.git') {
    return true
  }

  return false
}

emptyDir

判断是否是一个空目录

function emptyDir(dir) {
  if (!fs.existsSync(dir)) { // fs.exists() 如果路径存在,则返回 `true`,否则返回 `false`。
    return
  }

  postOrderDirectoryTraverse(
    dir,
    (dir) => fs.rmdirSync(dir), // 同步移除文件夹
    (file) => fs.unlinkSync(file) // 同步删除文件,但不删除文件夹
  )
}

index.ts入口文件中,我们发现init作为入口函数,我们来调试一下init函数。

init

async function init() {
  console.log()
  console.log( // 判断是否在终端上下文中和颜色深度???
    process.stdout.isTTY && process.stdout.getColorDepth() > 8
      ? banners.gradientBanner // 彩色的
      : banners.defaultBanner // 黑白的
  )
  console.log()

  const cwd = process.cwd() // 获取当前根路径
  // possible options:
  // --default
  // --typescript / --ts
  // --jsx
  // --router / --vue-router
  // --pinia
  // --with-tests / --tests (equals to `--vitest --cypress`)
  // --vitest
  // --cypress
  // --playwright
  // --eslint
  // --eslint-with-prettier (only support prettier through eslint for simplicity)
  // --force (for force overwriting)
  
  const argv = minimist(process.argv.slice(2), { // 解析命令行参数 argv位启动目录与当前文件目录
    alias: {
      typescript: ['ts'],
      'with-tests': ['tests'],
      router: ['vue-router']
    },
    string: ['_'],
    // all arguments are treated as booleans
    boolean: true
  })

  // if any of the feature flags is set, we would skip the feature prompts
  如果设置了这些特性,那么下一次会跳过走预设配置缓存
  const isFeatureFlagsUsed =
    typeof (
      argv.default ??
      argv.ts ??
      argv.jsx ??
      argv.router ??
      argv.pinia ??
      argv.tests ??
      argv.vitest ??
      argv.cypress ??
      argv.playwright ??
      argv.eslint
    ) === 'boolean'

  let targetDir = argv._[0]
  const defaultProjectName = !targetDir ? 'vue-project' : targetDir // 默认目录

  const forceOverwrite = argv.force // 强制覆盖

可选配置项

  // 这时候会在终端弹出可选命令项,这个result就是保存这些选项的。
  let result: {
    projectName?: string
    shouldOverwrite?: boolean
    packageName?: string
    needsTypeScript?: boolean
    needsJsx?: boolean
    needsRouter?: boolean
    needsPinia?: boolean
    needsVitest?: boolean
    needsE2eTesting?: false | 'cypress' | 'playwright'
    needsEslint?: boolean
    needsPrettier?: boolean
  } = {}

  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 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: 'shouldOverwrite',
          type: () => (canSkipEmptying(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: 'needsVitest',
          type: () => (isFeatureFlagsUsed ? null : 'toggle'),
          message: 'Add Vitest for Unit Testing?',
          initial: false,
          active: 'Yes',
          inactive: 'No'
        },
        {
          name: 'needsE2eTesting',
          type: () => (isFeatureFlagsUsed ? null : 'select'),
          message: 'Add an End-to-End Testing Solution?',
          initial: 0,
          choices: (prev, answers) => [
            { title: 'No', value: false },
            {
              title: 'Cypress',
              description: answers.needsVitest
                ? undefined
                : 'also supports unit testing with Cypress Component Testing',
              value: 'cypress'
            },
            {
              title: 'Playwright',
              value: 'playwright'
            }
          ]
        },
        {
          name: 'needsEslint',
          type: () => (isFeatureFlagsUsed ? null : 'toggle'),
          message: 'Add ESLint for code quality?',
          initial: false,
          active: 'Yes',
          inactive: 'No'
        },
        {
          name: 'needsPrettier',
          type: (prev, values) => {
            if (isFeatureFlagsUsed || !values.needsEslint) {
              return null
            }
            return 'toggle'
          },
          message: 'Add Prettier for code formatting?',
          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 {
    projectName,
    packageName = projectName ?? defaultProjectName,
    shouldOverwrite = argv.force,
    needsJsx = argv.jsx,
    needsTypeScript = argv.typescript,
    needsRouter = argv.router,
    needsPinia = argv.pinia,
    needsVitest = argv.vitest || argv.tests,
    needsEslint = argv.eslint || argv['eslint-with-prettier'],
    needsPrettier = argv['eslint-with-prettier']
  } = result

  const { needsE2eTesting } = result
  const needsCypress = argv.cypress || argv.tests || needsE2eTesting === 'cypress'
  const needsCypressCT = needsCypress && !needsVitest
  const needsPlaywright = argv.playwright || needsE2eTesting === 'playwright'

  const root = path.join(cwd, targetDir) // 获取根目录

  if (fs.existsSync(root) && shouldOverwrite) {
    emptyDir(root) //如果根目录是空的,并且强制覆盖那就先删除,再新建
  } else if (!fs.existsSync(root)) {
    fs.mkdirSync(root) // 如果没有文件,则直接创建
  }

  console.log(`\nScaffolding project in ${root}...`)

  // 生成package.json,写入name、version
  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 __filenameNew = fileURLToPath(import.meta.url) // 获取root
  const __dirnameNew = path.dirname(__filenameNew)
  const templateRoot = path.resolve(__dirnameNew, 'template')
  const render = function render(templateName) {
    const templateDir = path.resolve(templateRoot, templateName)
    // 这个函数,就是按照配置加载包的defaultConfig的,之后merge进,package.json中
    renderTemplate(templateDir, root) 
  }

根据用户配置加载包的config

  // Render base template
  render('base')

  // Add configs.
  if (needsJsx) {
    render('config/jsx')
  }
  if (needsRouter) {
    render('config/router')
  }
  if (needsPinia) {
    render('config/pinia')
  }
  if (needsVitest) {
    render('config/vitest')
  }
  if (needsCypress) {
    render('config/cypress')
  }
  if (needsCypressCT) {
    render('config/cypress-ct')
  }
  if (needsPlaywright) {
    render('config/playwright')
  }
  if (needsTypeScript) {
    render('config/typescript')

    // Render tsconfigs
    render('tsconfig/base')
    if (needsCypress) {
      render('tsconfig/cypress')
    }
    if (needsCypressCT) {
      render('tsconfig/cypress-ct')
    }
    if (needsPlaywright) {
      render('tsconfig/playwright')
    }
    if (needsVitest) {
      render('tsconfig/vitest')
    }
  }

  // Render ESLint config
  if (needsEslint) {
    renderEslint(root, { needsTypeScript, needsCypress, needsCypressCT, needsPrettier })
  }

  // 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.

  // We try to share as many files between TypeScript and JavaScript as possible.
  // If that's not possible, we put `.ts` version alongside the `.js` one in the templates.
  // So after all the templates are rendered, we need to clean up the redundant files.
  // (Currently it's only `cypress/plugin/index.ts`, 
  but we might add more in the future.)
  // (Or, we might completely get rid of the plugins 
  folder as Cypress 10 supports `cypress.config.ts`)

  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')) {
          const tsFilePath = filepath.replace(/\.js$/, '.ts')
          if (fs.existsSync(tsFilePath)) {
            fs.unlinkSync(filepath)
          } else {
            fs.renameSync(filepath, tsFilePath)
          }
        } else if (path.basename(filepath) === 'jsconfig.json') {
          fs.unlinkSync(filepath)
        }
      }
    ) 

生成入口index.html文件

    // Rename entry in `index.html`
    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
    preOrderDirectoryTraverse(
      root,
      () => {},
      (filepath) => {
        if (filepath.endsWith('.ts')) {
          fs.unlinkSync(filepath)
        }
      }
    )
  }

包管理器的选择使用

  const userAgent = process.env.npm_config_user_agent ?? ''
  const packageManager = /pnpm/.test(userAgent) ? 'pnpm' : /yarn/.test(userAgent) ? 'yarn' : 'npm'

生成README.md

  fs.writeFileSync(
    path.resolve(root, 'README.md'),
    generateReadme({
      projectName: result.projectName ?? result.packageName ?? defaultProjectName,
      packageManager,
      needsTypeScript,
      needsVitest,
      needsCypress,
      needsPlaywright,
      needsCypressCT,
      needsEslint
    })
  )

终端输出,初始化完成

  console.log(`\nDone. Now run:\n`)
  if (root !== cwd) {
    console.log(`  ${bold(green(`cd ${path.relative(cwd, root)}`))}`) // cd myproject
  }
  console.log(`  ${bold(green(getCommand(packageManager, 'install')))}`) // npm|yarn|pnpm install
  if (needsPrettier) {
    console.log(`  ${bold(green(getCommand(packageManager, 'lint')))}`) // eslint检查  npm|yarn|pnpm lint
  }
  console.log(`  ${bold(green(getCommand(packageManager, 'dev')))}`) // 启动 npm|yarn|pnpm dev
  console.log()
}

到这里就可以在根目录生成一个项目,我们可以用vite来启动项目啦!

总结

create-vue不仅仅自己写了包名核检函数,还有终端颜色输出的模块,这个脚手架支持jsxpinia等多种配置,每一种配置又是一个单独的包,整个文件下来就是以前的那一套,可选配置、package.json的生成、配置合并、终端输出等。这里仅仅是初始化一个项目而已,更重要的是用vite来启动服务,构建打包,值得一提的是,在开发环境vite打包是是用的自己的一套(esBuild),在生产环境vite直接借用了rollup工具进行打包,这个在我的前端构建工具vite进阶系列将会详细介绍。