Vue 團隊公開快如閃電的全新腳手架工具 create-vue,未來將替代 Vue-CLI,才300餘行程式碼,學它!

若川發表於2021-10-26

1. 前言

大家好,我是若川。歡迎關注我的公眾號若川視野,最近組織了原始碼共讀活動,感興趣的可以加我微信 ruochuan12 參與,已進行兩個多月,大家一起交流學習,共同進步。

想學原始碼,極力推薦之前我寫的《學習原始碼整體架構系列》 包含jQueryunderscorelodashvuexsentryaxiosreduxkoavue-devtoolsvuex4koa-composevue-next-releasevue-this等十餘篇原始碼文章。

美國時間 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

估計大多數讀者,第一反應是這樣竟然也可以,這麼簡單快捷?

忍不住想動手在控制檯輸出命令,我在終端試過,見下圖。

npm init vue@next

最終cd vue3-projectnpm installnpm run dev開啟頁面http://localhost:3000

初始化頁面

2.1 npm init && npx

為啥 npm init 也可以直接初始化一個專案,帶著疑問,我們翻看 npm 文件。

npm init

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

npm init <initializer> 時轉換成npx命令:

  • 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

我們可以在這裡create-vue,找到一些資訊。或者在npm create-vue找到版本等資訊。

其中@next是指定版本,通過npm dist-tag ls create-vue命令可以看出,next版本目前對應的是3.0.0-beta.6

npm dist-tag ls create-vue
- latest: 3.0.0-beta.6
- next: 3.0.0-beta.6

釋出時 npm publish --tag next 這種寫法指定 tag。預設標籤是latest

可能有讀者對 npx 不熟悉,這時找到阮一峰老師部落格 npx 介紹nodejs.cn npx

npx 是一個非常強大的命令,從 npm 的 5.2 版本(釋出於 2017 年 7 月)開始可用。

簡單說下容易忽略且常用的場景,npx有點類似小程式提出的隨用隨走。

輕鬆地執行本地命令

node_modules/.bin/vite -v
# vite/2.6.5 linux-x64 node-v14.16.0

# 等同於
# package.json script: "vite -v"
# npm run vite

npx vite -v
# vite/2.6.5 linux-x64 node-v14.16.0

使用不同的 Node.js 版本執行程式碼
某些場景下可以臨時切換 node 版本,有時比 nvm 包管理方便些。

npx node@14 -v
# v14.18.0

npx -p node@14 node -v 
# v14.18.0

無需安裝的命令執行

# 啟動本地靜態服務
npx http-server
# 無需全域性安裝
npx @vue/cli create vue-project
# @vue/cli 相比 npm init vue@next npx create-vue@next 很慢。

# 全域性安裝
npm i -g @vue/cli
vue create vue-project

npx vue-cli

npm init vue@nextnpx create-vue@next) 快的原因,主要在於依賴少(能不依賴包就不依賴),原始碼行數少,目前index.js只有300餘行。

3. 配置環境除錯原始碼

3.1 克隆 create-vue 專案

本文倉庫地址 create-vue-analysis,求個star~

# 可以直接克隆我的倉庫,我的倉庫保留的 create-vue 倉庫的 git 記錄
git clone https://github.com/lxchuan12/create-vue-analysis.git
cd create-vue-analysis/create-vue
npm i

當然不克隆也可以直接用 VSCode 開啟我的倉庫
Open in Visual Studio Code

順帶說下:我是怎麼保留 create-vue 倉庫的 git 記錄的。

# 在 github 上新建一個倉庫 `create-vue-analysis` 克隆下來
git clone https://github.com/lxchuan12/create-vue-analysis.git
cd create-vue-analysis
git subtree add --prefix=create-vue https://github.com/vuejs/create-vue.git main
# 這樣就把 create-vue 資料夾克隆到自己的 git 倉庫了。且保留的 git 記錄

關於更多 git subtree,可以看Git Subtree 簡明使用手冊

3.2 package.json 分析

// create-vue/package.json
{
  "name": "create-vue",
  "version": "3.0.0-beta.6",
  "description": "An easy way to start a Vue project",
  "type": "module",
  "bin": {
    "create-vue": "outfile.cjs"
  },
}

bin指定可執行指令碼。也就是我們可以使用 npx create-vue 的原因。

outfile.cjs 是打包輸出的JS檔案

{
  "scripts": {
    "build": "esbuild --bundle index.js --format=cjs --platform=node --outfile=outfile.cjs",
    "snapshot": "node snapshot.js",
    "pretest": "run-s build snapshot",
    "test": "node test.js"
  },
}

執行 npm run test 時,會先執行鉤子函式 pretestrun-snpm-run-all 提供的命令。run-s build snapshot 命令相當於 npm run build && npm run snapshot

根據指令碼提示,我們來看 snapshot.js 檔案。

3.3 生成快照 snapshot.js

這個檔案主要作用是根據const featureFlags = ['typescript', 'jsx', 'router', 'vuex', 'with-tests'] 組合生成31種加上 default 共計 32種 組合,生成快照在 playground目錄。

因為打包生成的 outfile.cjs 程式碼有做一些處理,不方便除錯,我們可以修改為index.js便於除錯。

// 路徑 create-vue/snapshot.js
const bin = path.resolve(__dirname, './outfile.cjs')
// 改成 index.js 便於除錯
const bin = path.resolve(__dirname, './index.js')

我們可以在forcreateProjectWithFeatureFlags 打上斷點。

createProjectWithFeatureFlags其實類似在終端輸入如下執行這樣的命令

node ./index.js --xxx --xxx --force
function createProjectWithFeatureFlags(flags) {
  const projectName = flags.join('-')
  console.log(`Creating project ${projectName}`)
  const { status } = spawnSync(
    'node',
    [bin, projectName, ...flags.map((flag) => `--${flag}`), '--force'],
    {
      cwd: playgroundDir,
      stdio: ['pipe', 'pipe', 'inherit']
    }
  )

  if (status !== 0) {
    process.exit(status)
  }
}

// 路徑 create-vue/snapshot.js
for (const flags of flagCombinations) {
  createProjectWithFeatureFlags(flags)
}
除錯VSCode開啟專案,VSCode高版本(1.50+)可以在 create-vue/package.json => scripts => "test": "node test.js"。滑鼠懸停在test上會有除錯指令碼提示,選擇除錯指令碼。如果對除錯不熟悉,可以看我之前的文章koa-compose,寫的很詳細。

除錯時,大概率你會遇到:create-vue/index.js 檔案中,__dirname 報錯問題。可以按照如下方法解決。在 import 的語句後,新增如下語句,就能愉快的除錯了。

// 路徑 create-vue/index.js
// 解決辦法和nodejs issues
// https://stackoverflow.com/questions/64383909/dirname-is-not-defined-in-node-14-version
// https://github.com/nodejs/help/issues/2907

import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

接著我們除錯 index.js 檔案,來學習。

4. 除錯 index.js 主流程

回顧下上文 npm init vue@next 初始化專案的。

npm init vue@next

單從初始化專案輸出圖來看。主要是三個步驟。

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-CLIvue create vue-project 命令是用官方的npmvalidate-npm-package-name,刪除資料夾一般都是使用 rimraf。而 create-vue 是自己實現emptyDirisValidPackageName

非常建議讀者朋友按照文中方法使用VSCode除錯 create-vue 原始碼。原始碼中還有很多細節文中由於篇幅有限,未全面展開講述。

學完本文,可以為自己或者公司建立類似初始化腳手架。

目前版本是3.0.0-beta.6。我們持續關注學習它。除了create-vue 之外,我們還可以看看create-vitecreate-umi 的原始碼實現。

最後歡迎加我微信 ruochuan12 交流,參與 原始碼共讀 活動,大家一起學習原始碼,共同進步。

7. 參考資料

發現 create-vue 時打算寫文章加入到原始碼共讀計劃中,大家一起學習。而原始碼共讀群裡小夥伴upupming比我先寫完文章。

@upupming vue-cli 將被 create-vue 替代?初始化基於 vite 的 vue3 專案為何如此簡單?

相關文章