热搜:fiddler git ip 代理 ios
历史搜索

Vue 团队全新脚手架工具 create

游客2025-01-08 16:30:01
目录文章目录
  1. 前言
  2. 2. 使用 npm init vue@next 初始化 vue3 项目
  3. 3. 配置环境调试源码
  4. 4. 调试 index.js 主流程
  5. 5. npm run test => node test.js 测试
  6. 6. 总结

前言

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

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

本文就是通过调试和大家一起学习这个 300 余行的源码。

阅读本文,你将学到:

1. 学会全新的官方脚手架工具 create-vue 的使用和原理
2. 学会使用 VSCode 直接打开 github 项目
3. 学会使用测试用例调试源码
4. 学以致用,为公司初始化项目写脚手架工具。
5. 等等

2. 使用 npm init vue@next 初始化 vue3 项目

create-vue github README上写着,An easy way to start a Vue project。一种简单的初始化 vue 项目的方式。

npm init vue@next

估计大多数读者,第一反应是这样竟然也可以,这么简单快捷?

忍不住想动手在控制台输出命令,我在终端试过,见下图。

Vue 团队全新脚手架工具 create 1

单从初始化项目输出图来看。主要是三个步骤。

  1. 输入项目名称,默认值是 vue-project;
  2. 询问一些配置 渲染模板等;
  3. 完成创建项目,输出运行提示。
async function init() {
  // 省略放在后文详细讲述
}

// async 函数返回的是 Promise 可以用 catch 报错
init().catch((e) => {
  console.error(e)
})

4.1 解析命令行参数

// 返回运行当前脚本的工作目录的路径。
const cwd = process.cwd()
// possible options:
// --default
// --typescript / --ts
// --jsx
// --router / --vue-router
// --vuex
// --with-tests / --tests / --cypress
// --force (for force overwriting)
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

简单说,这个库,就是解析命令行参数的。看例子,我们比较容易看懂传参和解析结果。

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

比如:

npm init vue@next --vuex --force

4.2 如果设置了 feature flags 跳过 prompts 询问

这种写法方便代码测试等。直接跳过交互式询问,同时也可以省时间。

// if any of the feature flags is set, we would skip the feature prompts
  // use `??` instead of `||` once we drop Node.js 12 support
  const isFeatureFlagsUsed =
    typeof (argv.default || argv.ts || argv.jsx || argv.router || argv.vuex || argv.tests) ===
    'boolean'

// 生成目录
  let targetDir = argv._[0]
  // 默认 vue-projects
  const defaultProjectName = !targetDir ? 'vue-project' : targetDir
  // 强制重写文件夹,当同名文件夹存在时
  const forceOverwrite = argv.force

4.3 交互式询问一些配置

如上文npm init vue@next 初始化的图示

  • 输入项目名称
  • 还有是否删除已经存在的同名目录
  • 询问使用需要 JSX Router vuex cypress 等。
let result = {}

  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 Vuex for state management? (TODO)
    // - Add Cypress for testing?
    result = await prompts(
      [
        {
          name: 'projectName',
          type: targetDir ? null : 'text',
          message: 'Project name:',
          initial: defaultProjectName,
          onState: (state) => (targetDir = String(state.value).trim() || defaultProjectName)
        },
        // 省略若干配置
        {
          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)
  }

4.4 初始化询问用户给到的参数,同时也会给到默认值

// `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,
    needsVuex = argv.vuex,
    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))

4.5 根据模板文件生成初始化项目所需文件

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

4.6 渲染生成代码模板

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

4.7 如果配置了需要 ts

重命名所有的 .js 文件改成 .ts
重命名 jsconfig.json 文件为 tsconfig.json 文件。

jsconfig.json 是 VSCode 的配置文件,可用于配置跳转等。

index.html 文件里的 main.js 重命名为 main.ts

// 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`
    const indexHtmlPath = path.resolve(root, 'index.html')
    const indexHtmlContent = fs.readFileSync(indexHtmlPath, 'utf8')
    fs.writeFileSync(indexHtmlPath, indexHtmlContent.replace('src/main.js', 'src/main.ts'))
  }

4.8 配置了不需要测试

因为所有的模板都有测试文件,所以不需要测试时,执行删除 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)
        }
      },
      () => {}
    )
  }

4.9 根据使用的 npm / yarn / pnpm 生成 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`.
  const packageManager = /pnpm/.test(process.env.npm_execpath)
    ? 'pnpm'
    : /yarn/.test(process.env.npm_execpath)
    ? 'yarn'
    : 'npm'

  // README generation
  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()

5. npm run test => node test.js 测试

// create-vue/test.js
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'

import { spawnSync } from 'child_process'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const playgroundDir = path.resolve(__dirname, './playground/')

for (const projectName of fs.readdirSync(playgroundDir)) {
  if (projectName.endsWith('with-tests')) {
    console.log(`Running unit tests in ${projectName}`)
    const unitTestResult = spawnSync('pnpm', ['test:unit:ci'], {
      cwd: path.resolve(playgroundDir, projectName),
      stdio: 'inherit',
      shell: true
    })
    if (unitTestResult.status !== 0) {
      throw new Error(`Unit tests failed in ${projectName}`)
    }

    console.log(`Running e2e tests in ${projectName}`)
    const e2eTestResult = spawnSync('pnpm', ['test:e2e:ci'], {
      cwd: path.resolve(playgroundDir, projectName),
      stdio: 'inherit',
      shell: true
    })
    if (e2eTestResult.status !== 0) {
      throw new Error(`E2E tests failed in ${projectName}`)
    }
  }
}

主要对生成快照时生成的在 playground 32 个文件夹,进行如下测试。

pnpm test:unit:ci

pnpm test:e2e:ci

6. 总结

我们使用了快如闪电般的npm init vue@next,学习npx命令了。学会了其原理。

npm init vue@next => npx create-vue@next

之所以快如闪电,就在于依赖的很少,很多都是自己来实现。如:Vue-CLI中 vue create vue-project 命令是用官方的npmvalidate-npm-package-name,删除文件夹一般都是使用 rimraf。而 create-vue 是自己实现emptyDirisValidPackageName

非常建议读者朋友按照文中方法使用VSCode调试 create-vue 源码。源码中还有很多细节文中由于篇幅有限,未全面展开讲述。

学完本文,可以为自己或者公司创建类似初始化脚手架。

目前版本是3.0.0-beta.6。我们持续关注学习它。除了 create-vue 之外,我们还可以看看create-vitecreate-umi 的源码实现。