国产 无码 综合区,色欲AV无码国产永久播放,无码天堂亚洲国产AV,国产日韩欧美女同一区二区

【源碼】Vue.js 官方腳手架 create-vue 是怎么實現(xiàn)的?

這篇具有很好參考價值的文章主要介紹了【源碼】Vue.js 官方腳手架 create-vue 是怎么實現(xiàn)的?。希望對大家有所幫助。如果存在錯誤或未考慮完全的地方,請大家不吝賜教,您也可以點擊"舉報違法"按鈕提交疑問。

Vue.js 官方腳手架 create-vue 是怎么實現(xiàn)的?

摘要

本文共分為四個部分,系統(tǒng)解析了vue.js 官方腳手架 create-vue 的實現(xiàn)細節(jié)。

第一部分主要是一些準(zhǔn)備工作,如源碼下載、項目組織結(jié)構(gòu)分析、依賴分析、功能點分析等;

第二部分分析了 create-vue 腳手架是如何執(zhí)行的,執(zhí)行文件的生成細節(jié);

第三部分是本文的核心部分,主要分析了終端交互和配置讀取的實現(xiàn)細節(jié)、腳手架工程生成細節(jié);

第四部分則是 create-vue 的打包、快照、預(yù)發(fā)布等腳本的實現(xiàn)。

?? 全文近2萬字,閱讀耗時長,建議收藏慢慢看哦!

原文地址 + 詳細注釋版源碼地址

create-vue-code-analysis

?? 如果覺得還可以的話,可以給個??嗎,嘿嘿嘿!

準(zhǔn)備工作

1. 獲取源碼 ??

源碼地址:create-vue

源碼版本:3.6.4

目錄結(jié)構(gòu)如下:

2. package.json文件預(yù)覽 ??

如果希望系統(tǒng)分析源碼,建議先看看整個項目中用到了那些依賴,那些技術(shù)點,都是干啥的?對于一些不了解的依賴,可以提前了解一下。也可以通過依賴大致分析出項目的解決思路。

以下是 create-vuepackage.json 文件。

{
  "name": "create-vue",
  "version": "3.6.4",
  "description": "An easy way to start a Vue project",
  "type": "module",
  "bin": {
    "create-vue": "outfile.cjs"
  },
  "files": [
    "outfile.cjs",
    "template"
  ],
  "engines": {
    "node": ">=v16.20.0"
  },
  "scripts": {
    "prepare": "husky install",
    "format": "prettier --write .",
    "build": "zx ./scripts/build.mjs",
    "snapshot": "zx ./scripts/snapshot.mjs",
    "pretest": "run-s build snapshot",
    "test": "zx ./scripts/test.mjs",
    "prepublishOnly": "zx ./scripts/prepublish.mjs"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/vuejs/create-vue.git"
  },
  "keywords": [],
  "author": "Haoqun Jiang <haoqunjiang+npm@gmail.com>",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/vuejs/create-vue/issues"
  },
  "homepage": "https://github.com/vuejs/create-vue#readme",
  "devDependencies": {
    "@tsconfig/node18": "^2.0.1", 
    "@types/eslint": "^8.37.0",
    "@types/node": "^18.16.8",
    "@types/prompts": "^2.4.4",
    "@vue/create-eslint-config": "^0.2.1",
    "@vue/tsconfig": "^0.4.0",
    "esbuild": "^0.17.18",
    "esbuild-plugin-license": "^1.2.2",
    "husky": "^8.0.3",
    "kolorist": "^1.8.0",
    "lint-staged": "^13.2.2",
    "minimist": "^1.2.8",
    "npm-run-all": "^4.1.5",
    "prettier": "^2.8.8",
    "prompts": "^2.4.2",
    "zx": "^4.3.0"
  },
  "lint-staged": {
    "*.{js,ts,vue,json}": [
      "prettier --write"
    ]
  }
}

下表中一句話簡單介紹了每個包的作用:

依賴名 功能
@tsconfig/node18 node.js18配套的tsconfig
@types/eslint eslint相關(guān)
@types/node node.js的類型定義
@vue/create-eslint-config 在Vue.js項目中設(shè)置ESLint的實用程序。
@vue/tsconfig 用于Vue項目的TS Configure擴展。
esbuild JavaScript 和 golang 打包工具,在打包時使用,后文會具體分析,建議預(yù)先了解,尤其是 esbuild.build 函數(shù)的相關(guān) Api
esbuild-plugin-license 許可證生成插件,用于在打包時,生成 LICENSE 文件
husky git 鉤子,代碼提交規(guī)范工具
kolorist 給stdin/stdout的文本內(nèi)容添加顏色,建議預(yù)先了解。
lint-staged 格式化代碼
minimist 解析參數(shù)選項, 當(dāng)用戶從 terminal 中輸入命令指令時,幫助解析各個參數(shù)的工具,建議預(yù)先了解。
npm-run-all 一個CLI工具,用于并行或順序運行多個npm-script。
prettier 代碼格式化
prompts 輕巧、美觀、人性化的交互式提示。在 terminal 中做對話交互的。建議預(yù)先了解。
@types/prompts prompts 庫的類型定義
zx 該庫為開發(fā)者在JavaScript中編寫shell腳本提供了一系例功能,使開發(fā)更方便快捷,主要在編寫復(fù)雜的 script 命令是使用。只要在打包,快照,預(yù)發(fā)布階段編寫腳本時使用,建議預(yù)先了解。

可以看到,這16個依賴,真正和cli功能緊密相關(guān)的應(yīng)該是以下幾個:

  • esbuild
  • kolorist
  • minimist
  • prompts
  • zx

所以,學(xué)習(xí) create-vue 的之前,可以先閱讀以下幾個庫的基本使用方法,以便在閱讀源碼過程中,遇到有知識點盲區(qū)的,可以定向?qū)W習(xí)。

3. 項目目錄結(jié)構(gòu)及功能模塊簡介 ??

F:\Study\Vue\Code\VueSourceCode\create-vue
├── CONTRIBUTING.md
├── create-vue-tree.txt
├── index.ts
├── LICENSE
├── media
|  └── screenshot-cli.png
├── package.json
├── playground
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
├── renovate.json
├── scripts
|  ├── build.mjs
|  ├── prepublish.mjs
|  ├── snapshot.mjs
|  └── test.mjs
├── template
|  ├── base
|  ├── code
|  ├── config
|  ├── entry
|  ├── eslint
|  └── tsconfig
├── tsconfig.json
└── utils
   ├── banners.ts
   ├── deepMerge.ts
   ├── directoryTraverse.ts
   ├── generateReadme.ts
   ├── getCommand.ts
   ├── renderEslint.ts
   ├── renderTemplate.ts
   └── sortDependencies.ts

可以看到,除開一些配置文件和空文件夾后,真正用到的文件就以下幾個部分:

  • husky

這個是跟代碼提交相關(guān)的,跟核心功能無關(guān)了。

  • scripts

主要是項目打包,生成快照,預(yù)發(fā)布,單元測試這幾塊內(nèi)容的 script 腳本.

  • template

這個里面是這個庫的核心部分了——模板。我們 cli 在執(zhí)行時,就是會從這個文件夾中讀取各個配置的代碼,最后把這些代碼組合成一個完整的項目,然后給用戶快速生成一個項目模板。

這個模板只要你事先預(yù)制好即可。

  • utils

項目中用到的一些工具方法,可在用到的時候再具體去分析。

  • index.ts

項目的入口文件,也是核心文件,包含了cli 執(zhí)行的所有邏輯,是create-vue腳手架的核心實現(xiàn)。

以上就是項目中一些具體模塊的大致功能了,最最核心的還是 index.ts 文件,我們將在第四個部分具體分析。

create-vue是如何工作的?

1. 腳手架命令是怎么使用的????

下面是 create-vue 的用法:

npm create vue@3

常見的npm命令有 npm init, npm run, npm install等,但是 create 命令很少見,這里我們先看下,運行npm create 會發(fā)生什么:

?? 參考資料:npm create vite“ 是如何實現(xiàn)初始化 Vite 項目?

npm init / create 命令

npm v6 版本給 init 命令添加了別名 create,倆命令是一樣的.

npm init 命令除了可以用來創(chuàng)建 package.json 文件,還可以用來執(zhí)行一個包的命令;它后面還可以接一個 <initializer> 參數(shù)。該命令格式:

npm init <initializer>

參數(shù) initializer 是名為 create-<initializer>npm 包 ( 例如 create-vite ),執(zhí)行 npm init <initializer> 將會被轉(zhuǎn)換為相應(yīng)的 npm exec 操作,即會使用 npm exec 命令來運行 create-<initializer> 包中對應(yīng)命令 create-<initializer>package.json 的 bin 字段指定),例如:

# 使用 create-vite 包的 create-vite 命令創(chuàng)建一個名為 my-vite-project 的項目
$ npm init vite my-vite-project
# 等同于
$ npm exec create-vite my-vite-project

執(zhí)行 npm create vite 發(fā)生了什么?

當(dāng)我們執(zhí)行 npm create vite 時,會先補全包名為 create-vite,轉(zhuǎn)換為使用 npm exec 命令執(zhí)行,即 npm exec create-vite,接著執(zhí)行包對應(yīng)的 create-vite 命令(如果本地未安裝 create-vite 包則先安裝再執(zhí)行).

所以說,我們在執(zhí)行命令時,npm 會執(zhí)行create-vue 這個包,來運行 cli ,搭建工程。

具體的執(zhí)行過程如下:

  • npm create vue 轉(zhuǎn)化 npm exec create-vue 命令,執(zhí)行 create-vue 包;

  • 執(zhí)行 create-vue 具體是執(zhí)行啥?執(zhí)行 package.json 中bin 字段對應(yīng)的 .js 文件。

{
   "name": "create-vue",
  "version": "3.6.4",
  "description": "An easy way to start a Vue project",
  "type": "module",
  "bin": {
    "create-vue": "outfile.cjs"
  }
}

這是 package.json 中的部分代碼,運行以上命令,就對應(yīng)會執(zhí)行 outfile.cjs 文件,其最終的結(jié)果即:

node outfile.cjs

如下圖,在create-vue的包中直接執(zhí)行node outfile.cjs , 執(zhí)行結(jié)果跟執(zhí)行腳手架一致,這證明了以上的結(jié)論正確。

2. outfile.cjs 文件從何而來??

outfile.cjs 從何而來呢?outfile.cjs是打包后的文件,這是顯然的。我們找到腳手架打包的位置,查看一下它的入口文件即可。

根據(jù) script 命令,打包命令"build": "zx ./scripts/build.mjs", 指向 build.mjs

// build.mjs
await esbuild.build({
  bundle: true,
  entryPoints: ['index.ts'],
  outfile: 'outfile.cjs',
  format: 'cjs',
  platform: 'node',
  target: 'node14',
    ...

入口文件就是 index.ts 了。

所以,我們后續(xù)的分析將主要集中在 index.ts 文件。

create-vue 的核心實現(xiàn)細節(jié)

相比于動輒幾萬行的庫來說,index.ts 并不復(fù)雜,包括注釋,只有短短的460多行。
我們先整體瀏覽一遍代碼,大致了解下這個文件是怎么做到在短短400多行就完成了一個如此解除的腳手架。

經(jīng)過3分鐘的整體瀏覽,我們帶著懵逼的情緒劃到了文件的最底部,看到了這個程序的真諦,短短3行:

init().catch((e) => {
  console.error(e)
})

一切的一起都圍繞 init 方法展開。

接下來,我們一步一步,庖丁解牛,揭開美人的面紗。移步 init 方法。

// index.ts
async function init() {
  ...

  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?`
          }
        },
        ....
      ],
      {
        onCancel: () => {
          throw new Error(red('?') + ' Operation cancelled')
        }
      }
    )
  } catch (cancelled) {
    console.log(cancelled.message)
    process.exit(1)
  }

  ...

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

程序有點長,但是流程非常清晰直白,相比于 vue cli 的各種回調(diào)方法處理,它沒有一絲一毫的拐彎抹角,對于想入門源碼分析的同學(xué)比較友好。其實也就兩個部分:

  • 詢問用戶需要什么配置的腳手架工程;

  • 根據(jù)用戶配置生成相應(yīng)的腳手架工程;

下面首先分析第一部分:獲取用戶自定義配置;

1. 實現(xiàn)終端的交互,獲取用戶的自定義配置 ??

分析代碼總是枯燥的,但是既然是讀源碼,那再枯燥也得堅持。最終我們還得回到代碼上,逐行解析。請看

 console.log()
  console.log(
    // 確定 Node.js 是否在終端上下文中運行的首選方法是檢查 process.stdout.isTTY 屬性的值是否為 true
    process.stdout.isTTY && process.stdout.getColorDepth() > 8
      ? banners.gradientBanner
      : banners.defaultBanner
  )
  console.log()

這段代碼主要就是為了實現(xiàn)腳本執(zhí)行的這行標(biāo)題了,判斷腳本是否在終端中執(zhí)行,然后判斷終端環(huán)境是否能支持漸變色相關(guān)的能力,支持則輸出一個漸變色的炫酷的 banner 提示,否則輸出一個默認(rèn)的樸素的 banner 提示。花里胡哨,但真的很好看啊。

  const cwd = process.cwd() // 當(dāng)前node.js 進程執(zhí)行時的工作目錄

也就是你在那個目錄執(zhí)行 create-vue, cwd 就是相應(yīng)的目錄了。

  // 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), {
    alias: {
      typescript: ['ts', 'TS'],
      'with-tests': ['tests'],
      router: ['vue-router']
    },
    string: ['_'], // 布爾值、字符串或字符串?dāng)?shù)組,始終視為布爾值。如果為true,則將所有不帶等號的'--'參數(shù)視為布爾值
    // all arguments are treated as booleans
    boolean: true
  })
  console.log('argv:', argv)
  • ?? process.argv

process.argv 屬性返回數(shù)組,其中包含啟動 Node.js 進程時傳入的命令行參數(shù)。其中第一個元素是 Node.js 的可執(zhí)行文件路徑,第二個元素是當(dāng)前執(zhí)行的 JavaScript 文件路徑,之后的元素是命令行參數(shù)。process.argv.slice(2),可去掉前兩個元素,只保留命令行參數(shù)部分。

如圖的示例,使用 ts-node 執(zhí)行 index.ts 文件,所以 process.argv 的第一個參數(shù)是 ts-node 的可執(zhí)行文件的路徑,第二個參數(shù)是被執(zhí)行的 ts 文件的路徑,也就是 index.ts 的路徑,如果有其他參數(shù),則從 process.argv 數(shù)組的第三個元素開始。

  • ?? minimist

[minimist](minimist - npm (npmjs.com))

是一個用于解析命令行參數(shù)的 JavaScript 函數(shù)庫。它可以將命令行參數(shù)解析為一個對象,方便在代碼中進行處理和使用。

minimist 的作用是將命令行參數(shù)解析為一個對象,其中參數(shù)名作為對象的屬性,參數(shù)值作為對象的屬性值。它可以處理各種類型的命令行參數(shù),包括帶有選項標(biāo)志的參數(shù)、帶有值的參數(shù)以及沒有值的參數(shù)。

minimist 函數(shù)返回一個對象,其中包含解析后的命令行參數(shù)。我們可以通過訪問對象的屬性來獲取相應(yīng)的命令行參數(shù)值。

假設(shè)我們在命令行中運行以下命令:

node example/parse.js -x 3 -y 4 -n 5 -abc --beep=boop foo bar baz

那么 minimist 將會解析這些命令行參數(shù),并將其轉(zhuǎn)換為以下對象:

{
   _: ['foo', 'bar', 'baz'],
   x: 3,
   y: 4,
   n: 5,
   a: true,
   b: true,
   c: true,
   beep: 'boop'
}

這樣,我們就可以在代碼中使用 argv.x和 argv.y來獲取相應(yīng)的參數(shù)值。

除了基本的用法外,minimist 還提供了一些高級功能,例如設(shè)置默認(rèn)值、解析布爾型參數(shù)等.

如:

const argv = minimist(args, opts={})

argv._ 包含所有沒有關(guān)聯(lián)選項的參數(shù);

'--' 之后的任何參數(shù)都不會被解析,并將以argv._ 結(jié)束。

options有:

  • opts.string: 要始終視為字符串的字符串或字符串?dāng)?shù)組參數(shù)名稱;

  • opts.boolean: 布爾值、字符串或字符串?dāng)?shù)組,始終視為布爾值。如果為true,則將所有不帶等號的'--'參數(shù)視為布爾值。如 opts.boolean 設(shè)為 true, 則 --save 解析為 save: true;

  • opts.default: 將字符串參數(shù)名映射到默認(rèn)值的對象;

  • opts.stopEarly: 當(dāng)為true時,用第一個非選項后的所有內(nèi)容填充argv._;

  • opts.alias: 將字符串名稱映射到字符串或字符串參數(shù)名稱數(shù)組,以用作別名的對象;

  • opts['--']: 當(dāng)為true時,用 '--' 之前的所有內(nèi)容填充argv._; 用 '--' 之后的所有內(nèi)容填充 argv['--'],如:

    minimist(('one two three -- four five --six'.split(' '), { '--': true }))
    
    // 結(jié)果
    {
      _: ['one', 'two', 'three'],
      '--': ['four', 'five', '--six']
    }
    
  • opts.unknown: 用一個命令行參數(shù)調(diào)用的函數(shù),該參數(shù)不是在opts配置對象中定義的。如果函數(shù)返回false,則未知選項不會添加到argv。

接下來舉例測試說明以上的possible options:選項得出的結(jié)果:

  • --default

    > ts-node index.ts up-web-vue --default
    
    // result:
    argv: { _: [ 'up-web-vue' ], default: true }
    
  • --typescript / --ts

    這個別名的配置,官方的解釋有點不太直觀,通過測試發(fā)現(xiàn),它是這個意思。

    alias: {
      typescript: ['ts', 'TS'],
      'with-tests': ['tests'],
      router: ['vue-router']
    },
    

    typescript: ['ts', 'TS'] 為例,輸入 typescript, ts, TS 任意一個, 將同時生成 以這 3 個字段為 key 的屬性,如:

    > ts-node index.ts up-web-vue --ts
    result:
    argv: 
    {
    	_: [ 'up-web-vue' ], 
    	typescript: true,
    	ts: true,
    	TS: true
    }
    

以上是2個典型的例子,根據(jù) minimist 的配置,當(dāng) options.booleantrue 時,所有 --youroption 后面不帶等號的,都視為youroption為布爾值,即: youroption: true, 所以,以上選型的結(jié)果依次為:

// --default
  // --typescript / --ts  // { typescript: true, ts: true, TS: true }
  // --jsx // { jsx: true }
  // --router / --vue-router // { router: true, vue-router: true }
  // --pinia // { pinia: true }
  // --with-tests / --tests // { 'with-tests': true, tests: true }
  // --vitest // { vitest: true }
  // --cypress // { cypress: true }
  // --playwright // { playwright: true }
  // --eslint // { eslint: true }
  // --eslint-with-prettier { 'eslint-with-prettier': true }
  // --force { force: true }

以上這一部分主要是在輸入 cli 指令時的一些選項的實現(xiàn)。

接下來是提示功能。提示功能會引導(dǎo)用戶根據(jù)自己的需求選擇不同的項目配置。

如圖所示,這是 create-vue 的提示模塊:

// if any of the feature flags is set, we would skip the feature prompts
// 翻譯一下:如果在上面講述的部分,已經(jīng)使用終端指令確定了安裝選項,那么下文中相關(guān)的模塊的提示項就會被跳過。
// 如果在 create-vue project-name 后附加了任何以下的選項,則此狀態(tài)為true,如:create-vue project-name --ts
// ?? 是 空值合并操作符,當(dāng)左側(cè)的操作數(shù)為 null 或者 undefined 時,返回其右側(cè)操作數(shù),否則返回左側(cè)操作數(shù)
  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

  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)
        },
        ...
      ],
      {
        onCancel: () => {
          throw new Error(red('?') + ' Operation cancelled')
        }
      }
    )
  } catch (cancelled) {
    console.log(cancelled.message)
    process.exit(1)
  }

先看第一段:

// if any of the feature flags is set, we would skip the feature prompts
// 翻譯一下:如果在上面講述的部分,已經(jīng)使用終端指令確定了安裝選項,那么下文中相關(guān)的提示項就會被跳過。
// 如果在 create-vue project-name 后附加了任何以下的選項,則此狀態(tài)為true,如:create-vue project-name --ts
// ?? 是 空值合并操作符,當(dāng)左側(cè)的操作數(shù)為 null 或者 undefined 時,返回其右側(cè)操作數(shù),否則返回左側(cè)操作數(shù)
  const isFeatureFlagsUsed =
    typeof (
      argv.default ??
      argv.ts ??
      argv.jsx ??
      argv.router ??
      argv.pinia ??
      argv.tests ??
      argv.vitest ??
      argv.cypress ??
      argv.playwright ??
      argv.eslint
    ) === 'boolean'
  
  // 以下是此字段控制的部分邏輯, 可以看到,當(dāng) isFeatureFlagsUsed 為 true 時,prompts 的type值為 null.此時此提示會跳過
  result = await prompts(
      [
          ....
      	{
          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'
        },
        ...
      ])

如果在 create-vue project-name 后附加了任何以下的選項,則此狀態(tài)為true,如:create-vue project-name --ts.

只要輸入任意一個選項,則以下的所有提示選項都將跳過,直接執(zhí)行 cli。以下是測試結(jié)果:

然后在是這一部分:

  let targetDir = argv._[0]
  const defaultProjectName = !targetDir ? 'vue-project' : targetDir
  const forceOverwrite = argv.force

根據(jù)以上對 argv 的解析,argv._ 是一個數(shù)組,其中的值是我們輸入的目標(biāo)文件夾名稱, 如下示例:

> ts-node index.ts up-web-vue

得到的 argv._ 的值為: ['up-web-vue'].

所以,targetDir 即為你希望腳手架為你生成的項目的文件夾名稱,defaultProjectName 則是未指定文件夾名稱時的默認(rèn)名稱。

argv.force 值即為 ts-node index.ts up-web-vue --forceforce 的值,是強制覆蓋選項的快捷指令,如果使用此選項,則后面則不會彈出詢問是否強制覆蓋的選項。

...
{
  name: 'shouldOverwrite',
      // forceOverwrite 為 true 時跳過
  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?`
  }
},
....
// 判斷 dir 文件夾是否是空文件夾
function canSkipEmptying(dir: string) {
  // dir目錄不存在,則當(dāng)然是空目錄
  if (!fs.existsSync(dir)) {
    return true
  }

  // 讀取dir內(nèi)的文件
  const files = fs.readdirSync(dir)
  // 無文件,當(dāng)然是空目錄
  if (files.length === 0) {
    return true
  }
  // 僅有.git目錄,則同樣視為一個空目錄
  if (files.length === 1 && files[0] === '.git') {
    return true
  }

  return false
}

當(dāng)然,如果目標(biāo)目錄是空母錄,自然也會跳過此提示。canSkipEmptying() 方法即用來判斷是否是空目錄。

  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
  } = {}
  // 定義一個result對象,prompts 提示的結(jié)果保存在此對象中。

以上定義一個 result ,從其類型的定義不難看出,該對象用來保存 prompts 提示結(jié)束后用戶選擇的結(jié)果。

接下來介紹生成項目之前的 prompts 提示部分。

首先,先大致了解下 prompts 庫的用法:

?? [prompts](prompts - npm (npmjs.com))

prompts 是一個用于創(chuàng)建交互式命令行提示的 JavaScript 庫。它可以方便地與用戶進行命令行交互,接收用戶輸入的值,并根據(jù)用戶的選擇執(zhí)行相應(yīng)的操作。在 prompts 中,問題對象(prompt object)是用于定義交互式提示的配置信息。它包含了一些屬性,用于描述問題的類型、提示信息、默認(rèn)值等。

下面是 prompt object 的常用屬性及其作用:

  • type:指定問題的類型,可以是 'text'、'number'、'confirm'、'select'、'multiselect'、‘null’ 等。不同的類型會影響用戶交互的方式和輸入值的類型。當(dāng)為 null 時會跳過,當(dāng)前對話。
  • name:指定問題的名稱,用于標(biāo)識用戶輸入的值。在返回的結(jié)果對象中,每個問題的名稱都會作為屬性名,對應(yīng)用戶的輸入值。
  • message:向用戶展示的提示信息??梢允且粋€字符串,也可以是一個函數(shù),用于動態(tài)生成提示信息。
  • initial:指定問題的默認(rèn)值。用戶可以直接按下回車鍵接受默認(rèn)值,或者輸入新的值覆蓋默認(rèn)值。
  • validate:用于驗證用戶輸入值的函數(shù)。它接受用戶輸入的值作為參數(shù),并返回一個布爾值或一個字符串。如果返回布爾值 false,表示輸入值無效;如果返回字符串,則表示輸入值無效的錯誤提示信息。
  • format:用于格式化用戶輸入值的函數(shù)。它接受用戶輸入的值作為參數(shù),并返回一個格式化后的值??梢杂糜趯斎胫颠M行預(yù)處理或轉(zhuǎn)換。
  • choices:用于指定選擇型問題的選項。它可以是一個字符串?dāng)?shù)組,也可以是一個對象數(shù)組。每個選項可以包含 title 和 value 屬性,分別用于展示選項的文本和對應(yīng)的值。
  • onRender:在問題被渲染到終端之前觸發(fā)的回調(diào)函數(shù)。它接受一個參數(shù) prompt,可以用于修改問題對象的屬性或執(zhí)行其他操作。例如,你可以在 onRender 回調(diào)中動態(tài)修改提示信息,根據(jù)不同的條件顯示不同的信息。
  • onState:在用戶輸入值發(fā)生變化時觸發(fā)的回調(diào)函數(shù)。它接受兩個參數(shù) state 和 prompt,分別表示當(dāng)前的狀態(tài)對象和問題對象。
  • onSubmit:在用戶完成所有問題的回答并提交之后觸發(fā)的回調(diào)函數(shù)。它接受一個參數(shù) result,表示用戶的回答結(jié)果。你可以在 onSubmit 回調(diào)中根據(jù)用戶的回答執(zhí)行相應(yīng)的操作,例如保存數(shù)據(jù)、發(fā)送請求等。

然后結(jié)合代碼來分析,每個提示項的作用分別是啥?

{
  name: 'projectName',
  type: targetDir ? null : 'text',
  message: 'Project name:',
  initial: defaultProjectName,
  onState: (state) => (targetDir = String(state.value).trim() || defaultProjectName)
},

項目名稱,這里如果在運行 cli 時未輸入項目名稱,則會展示此選項,默認(rèn)選項為 defaultProjectName, 在用戶確認(rèn)操作后,將輸入值作為生成目錄的文件夾名。

如果運行 cli 時已輸入項目名稱,則此提示會跳過。

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

以上兩個 prompts 為一組,在目標(biāo)文件夾為空時,不需要選擇強制覆蓋的配置,會跳過這2個 prompts 。

當(dāng)不為空時,首先會詢問是否強制覆蓋目標(biāo)目錄,其中 message 字段根據(jù)用戶輸入的 目錄名動態(tài)生成,這里特別考慮了 ‘.’ 這種目錄選擇方式(當(dāng)前目錄)。

type 即可以是字符串,布爾值,也可以是 Function ,當(dāng)為 Function 時,擁有2個默認(rèn)參數(shù) prevvalues, prve 表示前一個選項的選擇的值,values 表示已經(jīng)選擇了的所有選項的值。

此處根據(jù)上一個選項的選擇結(jié)果來決定下一個選項的類型。這段代碼中,當(dāng)用戶選擇不強制覆蓋目標(biāo)目錄時,則腳手架執(zhí)行終止,拋出 Operation cancelled 的錯誤提示。

{
  name: 'packageName',
  type: () => (isValidPackageName(targetDir) ? null : 'text'),
  message: 'Package name:',
  initial: () => toValidPackageName(targetDir),
  validate: (dir) => isValidPackageName(dir) || 'Invalid package.json name'
},  
...
// 校驗項目名
// 匹配一個項目名稱,它可以包含可選的 @scope/ 前綴,后面跟著一個或多個小寫字母、數(shù)字、-、. 或 ~ 中的任意一個字符。這個正則表達式適用于驗證類似于 npm 包名的項目名稱格式
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, '-')
}

以上代碼,對項目名進行校驗,看是否符合內(nèi)置的規(guī)則(類似于npm包名的格式) ,然后對不合法的字符進行校準(zhǔn),生成一個默認(rèn)的項目名,用戶可直接點擊確認(rèn)選擇使用這個默認(rèn)的項目名,或者重新輸入一次項目名,如果用戶再次輸入不合法的項目名,則會出現(xiàn)提示 Invalid package.json name, 然后無法繼續(xù)往下執(zhí)行,直到用戶修改為合法的 項目名。

{
    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'
},
          

以上這一組選項都是類似的,都是詢問是否添加某模塊,初始值為 false 默認(rèn)不添加,activeinactive 分別表示2個不同的選項,isFeatureFlagsUsed 前面已經(jīng)講過,這里略過。

所以這一段依次表示詢問用戶是否需要添加 TypeScriptJSX Support、Vue Router、Pinia、Vitest 。

接下來是e2e測試選項的 prompts:

{
  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'
    }
  ]
}

這里是一個選擇類型的 prompts,詢問是否添加 e2e 測試框架,共 3 個選項:

  • 不添加
  • 添加 cypress
  • 添加 playwright

接下里是 eslintprettier :

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

這2個選項為一組,其中如果選擇不集成 eslint, 則默認(rèn)也是不集成的 prettier 的,只有選擇集成eslint, 才可繼續(xù)選擇是否集成 prettier.

然后是 prompts 部分最后一個部分了,異常捕獲:

try {
  ...
} catch (cancelled) {
  console.log(cancelled.message)
  process.exit(1)
}

當(dāng)在選擇過程中按下終止快捷鍵(ctrl + c)時,或者在選擇過程中,觸發(fā)終止條件時(如上文中某選項的 throw new Error(red('?') + ' Operation cancelled') ),則會進入異常捕獲中,此時會打印任務(wù)執(zhí)行終止的提示,并結(jié)束此進程。

到此,腳手架第一個部分——“用戶自定義配置” 部分全都解析完成了,很好理解,就是使用一些更友好的形式(prompts)來收集用戶的需求,使用的工具也很簡單易懂。

接下里看一個 cli 真正的核心功能,根據(jù)用戶配置生成完整的項目結(jié)構(gòu)。

2. 根據(jù)用戶選擇生成合理的項目工程 ??

還是先貼代碼(后面針對具體的代碼還會再結(jié)合分析,此處可先大致瀏覽,然后迅速跳過):

// `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}...`)

  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')
  const render = function render(templateName) {
    const templateDir = path.resolve(templateRoot, templateName)
    renderTemplate(templateDir, root)
  }
  
  // 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)
        }
      }
    )

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

  // Instructions:
  // Supported package managers: pnpm > yarn > npm
  const userAgent = process.env.npm_config_user_agent ?? ''
  const packageManager = /pnpm/.test(userAgent) ? 'pnpm' : /yarn/.test(userAgent) ? 'yarn' : 'npm'

  // README generation
  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) {
    const cdProjectName = path.relative(cwd, root)
    console.log(
      `  ${bold(green(`cd ${cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName}`))}`
    )
  }
  console.log(`  ${bold(green(getCommand(packageManager, 'install')))}`)
  if (needsPrettier) {
    console.log(`  ${bold(green(getCommand(packageManager, 'format')))}`)
  }
  console.log(`  ${bold(green(getCommand(packageManager, 'dev')))}`)
  console.log()

第一部分是解析出用戶的自定義安裝配置項:

// `initial` won't take effect if the prompt type is null
// so we still have to assign the default values here
// 此處兼顧用戶從 prompts 配置讀取配置和直接使用 -- 指令進行快速配置。根據(jù)前面的分析,當(dāng)使用 -- 指令快速配置時,`prompts` 不生效,則從 result 中解構(gòu)出來的屬性都為 `undefined`, 此時,則會為其制定默認(rèn)值,也即是以下代碼中從 `argv` 中讀取的值。
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'

此處兼顧用戶從 prompts 配置讀取配置和直接使用 -- 指令進行快速配置。根據(jù)前面的分析,當(dāng)使用 -- 指令快速配置時,prompts 不生效,則從 result 中解構(gòu)出來的屬性都為 undefined, 此時,則會為其制定默認(rèn)值,也即是以下代碼中從 argv 中讀取的值。

緊接著開始進入文件操作階段了。

const root = path.join(cwd, targetDir) // 計算目標(biāo)文件夾的完整文件路徑

// 讀取目標(biāo)文件夾狀態(tài),看該文件夾是否是一個已存在文件夾,是否需要覆蓋
// 文件夾存在,則清空,文件夾不存在,則創(chuàng)建
if (fs.existsSync(root) && shouldOverwrite) {
  emptyDir(root)
} else if (!fs.existsSync(root)) {
  fs.mkdirSync(root)
}
// 一句提示, 腳手架項目在xxx目錄
console.log(`\nScaffolding project in ${root}...`)

// emptyDir
function emptyDir(dir) {
// 代碼寫的很嚴(yán)謹(jǐn)、健壯,即使外層調(diào)用的地方已經(jīng)判斷了目錄是否存在,在實際操作目錄中依然會重新判斷一下,與外部的業(yè)務(wù)代碼不產(chǎn)生多余的依賴關(guān)系。
  if (!fs.existsSync(dir)) {
    return
  }

  // 遍歷目錄,清空目錄里的文件夾和文件
  postOrderDirectoryTraverse(
    dir,
    (dir) => fs.rmdirSync(dir),
    (file) => fs.unlinkSync(file)
  )
}

// postOrderDirectoryTraverse  from './utils/directoryTraverse'
// /utils/directoryTraverse.ts
// 這個方法遞歸的遍歷給定文件夾,并對內(nèi)部的 文件夾 和 文件 按照給定的回調(diào)函數(shù)進行操作。
export function postOrderDirectoryTraverse(dir, dirCallback, fileCallback) {
  // 遍歷dir下的文件
  for (const filename of fs.readdirSync(dir)) {
    // 如果文件是 .git 文件,則跳過
    if (filename === '.git') {
      continue
    }
    const fullpath = path.resolve(dir, filename) // 計算文件路徑
    // 如果該文件是一個文件夾類型,則遞歸調(diào)用此方法,繼續(xù)對其內(nèi)部的文件進行操作
    if (fs.lstatSync(fullpath).isDirectory()) {
      postOrderDirectoryTraverse(fullpath, dirCallback, fileCallback)
      // 當(dāng)然遞歸完后,不要忘記對該文件夾自己進行操作
      dirCallback(fullpath)
      continue
    }
    // 如果該文件不是文件夾類型,是純文件,則直接執(zhí)行對單個文件的回調(diào)操作
    fileCallback(fullpath)
  }
}

以上部分主要是判斷目標(biāo)目錄的狀態(tài),清空目標(biāo)目錄的實現(xiàn)過程。

fs.unlinkSync: 同步刪除文件;

fs.rmdirSync: 同步刪除給定路徑下的目錄;

清空目標(biāo)目錄的實現(xiàn),其核心是通過 postOrderDirectoryTraverse 方法來遍歷目標(biāo)文件夾,通過傳入自定義的回調(diào)方法來決定對 filedirectory 執(zhí)行何種操作。此處對目錄執(zhí)行 (dir) => fs.rmdirSync(dir) 方法,來移除目錄,對文件執(zhí)行 (file) => fs.unlinkSync(file) 來移除。

// 定義腳手架工程的 package.json 文件的內(nèi)容,這里 packageName 和 projectName是保持一致的。
const pkg = { name: packageName, version: '0.0.0' }
// 創(chuàng)建一個 package.json 文件,寫入 pkg 變量定義的內(nèi)容
fs.writeFileSync(path.resolve(root, 'package.json'), JSON.stringify(pkg, null, 2))

fs.writeFileSync(file, data[, option])

  • file: 它是一個字符串,Buffer,URL或文件描述整數(shù),表示必須在其中寫入文件的路徑。使用文件描述符將使其行為類似于fs.write()方法。
  • data: 它是將寫入文件的字符串,Buffer,TypedArray或DataView。
  • options: 它是一個字符串或?qū)ο?,可用于指定將影響輸出的可選參數(shù)。它具有三個可選參數(shù):
    • encoding:它是一個字符串,它指定文件的編碼。默認(rèn)值為“ utf8”。
    • mode:它是一個整數(shù),指定文件模式。默認(rèn)值為0o666。
    • flag:它是一個字符串,指定在寫入文件時使用的標(biāo)志。默認(rèn)值為“ w”。

JSON.stringify(value[, replacer [, space]])

  • value: 將要序列化成 一個 JSON 字符串的值。

  • replacer: 可選, 如果該參數(shù)是一個函數(shù),則在序列化過程中,被序列化的值的每個屬性都會經(jīng)過該函數(shù)的轉(zhuǎn)換和處理;如果該參數(shù)是一個數(shù)組,則只有包含在這個數(shù)組中的屬性名才會被序列化到最終的 JSON 字符串中;如果該參數(shù)為 null 或者未提供,則對象所有的屬性都會被序列化。

  • space: 可選, 指定縮進用的空白字符串,用于美化輸出(pretty-print);如果參數(shù)是個數(shù)字,它代表有多少的空格;上限為 10。該值若小于 1,則意味著沒有空格;如果該參數(shù)為字符串(當(dāng)字符串長度超過 10 個字母,取其前 10 個字母),該字符串將被作為空格;如果該參數(shù)沒有提供(或者為 null),將沒有空格。

接下來正式進入模板渲染環(huán)節(jié)了。

__dirname 可以用來動態(tài)獲取當(dāng)前文件所屬目錄的絕對路徑;

__filename 可以用來動態(tài)獲取當(dāng)前文件的絕對路徑,包含當(dāng)前文件 ;

path.resolve() 方法是以程序為根目錄,作為起點,根據(jù)參數(shù)解析出一個絕對路徑;

// 計算模板所在文件加路徑
const templateRoot = path.resolve(__dirname, 'template')
// 定義模板渲染 render 方法,參數(shù)為模板名
const render = function render(templateName) {
    const templateDir = path.resolve(templateRoot, templateName)
    // 核心是這個 renderTemplate 方法,第一個參數(shù)是源文件夾目錄,第二個參數(shù)是目標(biāo)文件夾目錄
    renderTemplate(templateDir, root)
}

以上代碼定義了一個模板渲染方法,根據(jù)模板名,生成不同的項目模塊。

其核心為 renderTemplate 方法,下面來看此方法的代碼實現(xiàn):

// /utils/renderTemplate.ts
/**
 * Renders a template folder/file to the file system,
 * by recursively copying all files under the `src` directory,
 * with the following exception:
 *   - `_filename` should be renamed to `.filename`
 *   - Fields in `package.json` should be recursively merged
 * @param {string} src source filename to copy
 * @param {string} dest destination filename of the copy operation
 */
/** 翻譯一下這段函數(shù)說明:
 * 通過遞歸復(fù)制 src 目錄下的所有文件,將模板文件夾/文件 復(fù)制到 目標(biāo)文件夾,
 * 但以下情況例外:
 * 1. '_filename' 會被重命名為 '.filename';
 * 2. package.json 文件中的字段會被遞歸合并;
 */
function renderTemplate(src, dest) {
  const stats = fs.statSync(src) // src 目錄的文件狀態(tài)

  // 如果當(dāng)前src是文件夾,則在目標(biāo)目錄中遞歸的生成此目錄的子目錄和子文件,但 node_modules 忽略
  if (stats.isDirectory()) {
    // skip node_module
    if (path.basename(src) === 'node_modules') {
      return
    }

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

  // path.basename(path[, ext]) 返回 path 的最后一部分,也即是文件名了。
  const filename = path.basename(src)

  // 如果當(dāng)前src是單個文件,則直接復(fù)制到目標(biāo)路徑下,但有以下幾類文件例外,要特殊處理。
  // package.json 做合并操作,并對內(nèi)部的屬性的位置做了排序;
  // extensions.json 做合并操作;
  // 以 _ 開頭的文件名轉(zhuǎn)化為 . 開頭的文件名;
  // _gitignore 文件,將其中的配置追加到目標(biāo)目錄對應(yīng)文件中;
  if (filename === 'package.json' && fs.existsSync(dest)) {
    // merge instead of overwriting
    const existing = JSON.parse(fs.readFileSync(dest, 'utf8'))
    const newPackage = JSON.parse(fs.readFileSync(src, 'utf8'))
    const pkg = sortDependencies(deepMerge(existing, newPackage))
    fs.writeFileSync(dest, JSON.stringify(pkg, null, 2) + '\n')
    return
  }

  if (filename === 'extensions.json' && fs.existsSync(dest)) {
    // merge instead of overwriting
    const existing = JSON.parse(fs.readFileSync(dest, 'utf8'))
    const newExtensions = JSON.parse(fs.readFileSync(src, 'utf8'))
    const extensions = deepMerge(existing, newExtensions)
    fs.writeFileSync(dest, JSON.stringify(extensions, null, 2) + '\n')
    return
  }

  if (filename.startsWith('_')) {
    // rename `_file` to `.file`
    dest = path.resolve(path.dirname(dest), filename.replace(/^_/, '.'))
  }

  if (filename === '_gitignore' && fs.existsSync(dest)) {
    // append to existing .gitignore
    const existing = fs.readFileSync(dest, 'utf8')
    const newGitignore = fs.readFileSync(src, 'utf8')
    fs.writeFileSync(dest, existing + '\n' + newGitignore)
    return
  }

  fs.copyFileSync(src, dest)
}

// /utils/deepMerge.ts
const isObject = (val) => val && typeof val === 'object'
// 合并數(shù)組時,利用Set數(shù)據(jù)類型 對數(shù)組進行去重
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
}

// /utils/sortDependencies
// 將四種類型的依賴進行一個排序,共4種類型:dependencies、devDependencies、peerDependencies、optionalDependencies
// 這一步?jīng)]有很大的必要,不要也不影響功能,主要是為了將以上四個類型屬性在package.json中的位置統(tǒng)一一下,按照dependencies、devDependencies、peerDependencies、optionalDependencies 這個順序以此展示這些依賴項。
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
  }
}

以上代碼已添加了詳盡的注釋,學(xué)習(xí)時也可略過相關(guān)正文描述

renderTemplate 方法主要作用是將模板里的內(nèi)容按需渲染到腳手架工程中。主要涉及到一些文件的拷貝,合并,調(diào)增等操作。以上代碼已添加詳細注釋,這里將其中特殊處理的幾個點羅列如下,閱讀時著重注意即可:

  • 渲染模板時,如果模板中存在 'node_modules' 文件夾需跳過;
  • package.json 需要與腳手架工程中的package.json做合并操作, 并對內(nèi)部的屬性的位置做了排序;(按照 dependencies , devDependencies , peerDependencies, optionalDependencies 的順序)
  • extensions.json 需要與腳手架工程中的對應(yīng)文件做合并操作;
  • 以 _ 開頭的文件名轉(zhuǎn)化為 . 開頭的文件名;(如_a.ts => .a.ts);
  • _gitignore 文件,需要將其中的配置追加到目標(biāo)目錄對應(yīng)文件中;

接下來回到 index.ts 文件中繼續(xù)分析主流程:

// Render base template
// 渲染一個最基礎(chǔ)的基于 vite 的 vue3 項目 
render('base')

// Add configs.
if (needsJsx) {
    render('config/jsx')
}
...
// 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')
}
  // Render base template
  render('base')

首先渲染一個最基礎(chǔ)的基于 vitevue3 項目,除了 renderTemplate 方法中的一些特殊點之外, render('base') 中需要注意的一點是,這并不是一個完善的能跑的 vue3 工程,這里面缺少了 main.js 文件,這個文件會在后面的 Render entry file 部分進行補充。

如圖所示,無 main.j(t)s 文件.

緊接著是渲染用戶選擇的一些配置:

  • Jsx 配置:包括 package.json 需要安裝的依賴 和 vite.config.js 中的 相關(guān)配置:
// Add configs.
if (needsJsx) {
  render('config/jsx')
}
// package.json
{
  "dependencies": {
    "vue": "^3.3.2"
  },
  "devDependencies": {
    "@vitejs/plugin-vue-jsx": "^3.0.1", // 主要是這個 plugin-vue-jsx 插件
    "vite": "^4.3.5"
  }
}
// vite.config.js
import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue(), vueJsx()],// 主要是這里
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  }
})
  • router 配置:就一個package.json 中需要安裝的依賴
if (needsRouter) {
  render('config/router')
}
// package.json
{
  "dependencies": {
    "vue": "^3.3.2",
    "vue-router": "^4.2.0" // 這個
  }
}
  • pinia 配置:1. package.json 配置;2. 新增一個 pinia 使用 demo.
if (needsPinia) {
  render('config/pinia')
}
{
  "dependencies": {
    "pinia": "^2.0.36",
    "vue": "^3.3.2"
  }
}
  • Vitest 配置: 1. package.json 配置;2. vitest.config.js 文件;3. 一個單測用例示例;
if (needsVitest) {
  render('config/vitest')
}

具體內(nèi)容可看源碼的模板文件

  • Cypress/cypress-ct/playwright: 與上面操作類似,不贅述,直接看源碼的模板文件;
if (needsCypress) {
  render('config/cypress')
}
if (needsCypressCT) {
  render('config/cypress-ct')
}
if (needsPlaywright) {
  render('config/playwright')
}
  • TypeScript 配置:
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')
  }
}

TypeScript 的配置相對復(fù)雜一些,但根本上來說都是一些預(yù)先配置好的內(nèi)容,只要按需從對應(yīng)模版取正確的配置進行渲染,保證 TypeScript 的正常功能即可,亦不贅述。

  • Eslint 配置

接下是 eslint 相關(guān)配置的渲染,這塊是一個單獨的渲染函數(shù),跟 TypeScript, Cypress, CypressCT , Prettier 這幾個模塊相關(guān)。主要是一些針對行的處理邏輯,核心思路還是一樣,通過文件、配置的組合處理,來生成一個完整的功能配置。

// 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')
}
  • main.j(t)s配置
// 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')
}

前面提到 base 目錄中缺少 main.j(t)s 文件,這里給補上了。

  • ts 和 js 的差異化處理
// directoryTraverse.ts
function preOrderDirectoryTraverse(dir, dirCallback, fileCallback) {
  // 讀取目錄文件(同步)
  for (const filename of fs.readdirSync(dir)) {
    // 跳過.git文件
    if (filename === '.git') {
      continue
    }
    const fullpath = path.resolve(dir, filename)
    if (fs.lstatSync(fullpath).isDirectory()) {
      // 使用給定回調(diào)函數(shù)對文件夾進行處理
      dirCallback(fullpath)
      // in case the dirCallback removes the directory entirely
      // 遞歸調(diào)用方法前,先判斷文件夾是否存在,避免文件被刪除的情況
      if (fs.existsSync(fullpath)) {
        preOrderDirectoryTraverse(fullpath, dirCallback, fileCallback)
      }
      continue
    }
    fileCallback(fullpath)
  }
}

// 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`)
// 翻譯一下:我們嘗試在 TypeScript 和 JavaScript 之間復(fù)用盡可能多的文件。如果無法實現(xiàn)這一點,我們將同時保留“.ts”版本和“.js”版本。因此,在所有模板渲染完畢后,我們需要清理冗余文件。(目前只有'cypress/plugin/index.ts'是這種情況,但我們將來可能會添加更多。(或者,我們可能會完全擺脫插件文件夾,因為 Cypress 10 支持 'cypress.config.ts)
// 集成 ts 的情況下,對 js 文件做轉(zhuǎn)換,不集成 ts 的情況下,將模板中的 ts 相關(guān)的文件都刪除
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.
    // 將JS模板轉(zhuǎn)化為TS模板,先掃描所有的 js 文件,如果跟其同名的 ts 文件存在,則直接刪除 js 文件,否則將 js 文件重命名為 ts 文件
    // 直接刪除 jsconfig.json 文件
    preOrderDirectoryTraverse(
      root,
      () => {},
      (filepath) => {
        // 文件處理回調(diào)函數(shù):如果是 js 文件,則將其后綴變?yōu)?.ts 文件
        if (filepath.endsWith('.js')) {
          const tsFilePath = filepath.replace(/\.js$/, '.ts') // 先計算js文件對應(yīng)的ts文件的文件名
          // 如果已經(jīng)存在相應(yīng)的 ts 文件,則刪除 js 文件,否則將 js 文件重命名為 ts 文件
          if (fs.existsSync(tsFilePath)) {
            fs.unlinkSync(filepath)
          } else {
            fs.renameSync(filepath, tsFilePath)
          }
        } else if (path.basename(filepath) === 'jsconfig.json') { // 直接刪除 jsconfig.json 文件
          fs.unlinkSync(filepath)
        }
      }
    )

// Rename entry in `index.html`
// 讀取 index.html 文件內(nèi)容
const indexHtmlPath = path.resolve(root, 'index.html')
const indexHtmlContent = fs.readFileSync(indexHtmlPath, 'utf8')、
// 將 index.html 中的 main.js 的引入替換為 main.ts 的引入
fs.writeFileSync(indexHtmlPath, indexHtmlContent.replace('src/main.js', 'src/main.ts'))
} else {
    // Remove all the remaining `.ts` files
    // 將模板中的 ts 相關(guān)的文件都刪除
    preOrderDirectoryTraverse(
      root,
      () => {},
      (filepath) => {
        if (filepath.endsWith('.ts')) {
          fs.unlinkSync(filepath)
        }
      }
    )
}

最后是對集成 ts 的文件處理,這里依然是遞歸的對目錄進行掃描,針對文件夾和文件傳不同的回調(diào)函數(shù),做不同的處理。

在模板里面,大部分 js 和 ts 文件是可以復(fù)用的,只需要修改名字即可,但某些文件,差異比較大,無法復(fù)用,同時保留了 js 文件和 ts 文件2個版本。在處理的時候,對應(yīng)可復(fù)用文件,這里會按照是否集成 ts 對文件名進行修改,對不可復(fù)用文件,則會根據(jù)集成選項的不同,刪除 對應(yīng) js 文件或 ts 文件。

  • readme.md 文件
// Instructions:
// Supported package managers: pnpm > yarn > npm
const userAgent = process.env.npm_config_user_agent ?? '' // process.env.npm_config_user_agent 獲取當(dāng)前執(zhí)行的包管理器的名稱和版本
const packageManager = /pnpm/.test(userAgent) ? 'pnpm' : /yarn/.test(userAgent) ? 'yarn' : 'npm'

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

// generateReadme.ts
// 針對不同的操作,根據(jù)包管理工具的不同,輸出對應(yīng)的命令,主要是區(qū)分 yarn 和 (p)npm 吧,畢竟 pnpm 和 npm 命令差不多
export default function getCommand(packageManager: string, scriptName: string, args?: string) {
  if (scriptName === 'install') {
    return packageManager === 'yarn' ? 'yarn' : `${packageManager} install`
  }

  if (args) {
    return packageManager === 'npm'
      ? `npm run ${scriptName} -- ${args}`
      : `${packageManager} ${scriptName} ${args}`
  } else {
    return packageManager === 'npm' ? `npm run ${scriptName}` : `${packageManager} ${scriptName}`
  }
}

// generateReadme.ts
import getCommand from './getCommand'

const sfcTypeSupportDoc = [
  '',
  '## Type Support for `.vue` Imports in TS',
  '',
  'TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.',
  '',
  "If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:",
  '',
  '1. Disable the built-in TypeScript Extension',
  "    1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette",
  '    2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`',
  '2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.',
  ''
].join('\n')

export default function generateReadme({
  projectName,
  packageManager,
  needsTypeScript,
  needsCypress,
  needsCypressCT,
  needsPlaywright,
  needsVitest,
  needsEslint
}) {
  const commandFor = (scriptName: string, args?: string) => getCommand(packageManager, scriptName, args)
  let readme = `# ${projectName}

This template should help get you started developing with Vue 3 in Vite.

## Recommended IDE Setup

[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
${needsTypeScript ? sfcTypeSupportDoc : ''}
## Customize configuration

See [Vite Configuration Reference](https://vitejs.dev/config/).

## Project Setup
`

  let npmScriptsDescriptions = `\`\`\`sh
${commandFor('install')}
\`\`\`

### Compile and Hot-Reload for Development

\`\`\`sh
${commandFor('dev')}
\`\`\`

### ${needsTypeScript ? 'Type-Check, ' : ''}Compile and Minify for Production

\`\`\`sh
${commandFor('build')}
\`\`\`
`

  if (needsVitest) {
    npmScriptsDescriptions += `
### Run Unit Tests with [Vitest](https://vitest.dev/)

\`\`\`sh
${commandFor('test:unit')}
\`\`\`
`
  }

  if (needsCypressCT) {
    npmScriptsDescriptions += `
### Run Headed Component Tests with [Cypress Component Testing](https://on.cypress.io/component)

\`\`\`sh
${commandFor('test:unit:dev')} # or \`${commandFor('test:unit')}\` for headless testing
\`\`\`
`
  }

  if (needsCypress) {
    npmScriptsDescriptions += `
### Run End-to-End Tests with [Cypress](https://www.cypress.io/)

\`\`\`sh
${commandFor('test:e2e:dev')}
\`\`\`

This runs the end-to-end tests against the Vite development server.
It is much faster than the production build.

But it's still recommended to test the production build with \`test:e2e\` before deploying (e.g. in CI environments):

\`\`\`sh
${commandFor('build')}
${commandFor('test:e2e')}
\`\`\`
`
  }

  if (needsPlaywright) {
    npmScriptsDescriptions += `
### Run End-to-End Tests with [Playwright](https://playwright.dev)

\`\`\`sh
# Install browsers for the first run
npx playwright install

# When testing on CI, must build the project first
${commandFor('build')}

# Runs the end-to-end tests
${commandFor('test:e2e')}
# Runs the tests only on Chromium
${commandFor('test:e2e', '--project=chromium')}
# Runs the tests of a specific file
${commandFor('test:e2e', 'tests/example.spec.ts')}
# Runs the tests in debug mode
${commandFor('test:e2e', '--debug')}
\`\`\`
`
  }

  if (needsEslint) {
    npmScriptsDescriptions += `
### Lint with [ESLint](https://eslint.org/)

\`\`\`sh
${commandFor('lint')}
\`\`\`
`
  }

  readme += npmScriptsDescriptions
  return readme
}

生成 readme.md 的操作,主要是一些文本字符串的拼接操作,根據(jù)使用者的包管理工具的不同,生成不同的 readme.md 文檔。經(jīng)過前面的分析,再看這塊,就覺得很簡單了。

  • 打印新項目運行提示

最后一部分是新項目運行提示,也就是當(dāng)你的腳手架工程生成完畢后,告知你應(yīng)該如何啟動和運行你的腳手架工程。

console.log(`\nDone. Now run:\n`) // 打印提示
// 如果工程所在目錄與當(dāng)前目錄不是同一個目錄,則打印 cd xxx 指令
if (root !== cwd) {
  const cdProjectName = path.relative(cwd, root)
  console.log(
    `  ${bold(green(`cd ${cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName}`))}`
  )
}
// 根據(jù)包管理工具的不同,輸出不同的安裝以來指令
console.log(`  ${bold(green(getCommand(packageManager, 'install')))}`)
// 如果集成了 prettier, 輸出格式化指令
if (needsPrettier) {
  console.log(`  ${bold(green(getCommand(packageManager, 'format')))}`)
}
// 根據(jù)包管理工具的不同,輸出啟動腳手架工程的指令
console.log(`  ${bold(green(getCommand(packageManager, 'dev')))}`)
console.log()
// 結(jié)束

也就是如下圖所示的腳手架工程運行指引。

當(dāng)項目名稱帶有空格時,需要帶引號輸出。

到這里,關(guān)于 create-vue 的全部核心流程就都分析完畢了。

接下來看最后一個部分,項目的打包。

croate-vue 打包、快照和發(fā)布功能

1. build ??

一個工程化的項目,為了程序的可維護性等方面的需求,總是需要將不同功能的文件按照其職能進行分類管理,但是在實際使用過程中,不可能基于原始工程去直接使用,需要通過打包工具將項目打包為一個龐大的單文件。create-vue 打包指令如下:

"scripts": {
	"build": "zx ./scripts/build.mjs",
},

該指令執(zhí)行了 ./scripts/build.mjs 文件,接下來結(jié)合代碼來分析一下打包過程。

zx 是一個執(zhí)行腳本的工具,這在文初有簡單介紹,此處先不展開分析。直接看 build.mjs

import * as esbuild from 'esbuild'
import esbuildPluginLicense from 'esbuild-plugin-license'

const CORE_LICENSE = `MIT License

Copyright (c) 2021-present vuejs

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
`

await esbuild.build({
  bundle: true, // 是否打包
  entryPoints: ['index.ts'], // 入口
  outfile: 'outfile.cjs', // 出口
  format: 'cjs', // 輸出格式 commonJS
  platform: 'node', // 平臺
  target: 'node14',

  plugins: [
    // 此插件主要是解決 prompts 打包版本的問題,優(yōu)化打包大小。是社區(qū)作者貢獻的一段代碼
  	// prompts 在 node 版本小于8.6時,使用 prompts/dist 下的包(此目錄下的包經(jīng)過轉(zhuǎn)譯,體積稍大),在 node 版本大于 8.6 時,使用 prompts/lib 下的包
    // 參見如下提交
    // (https://github.com/vuejs/create-vue/pull/121)
  	// (https://github.com/terkelg/prompts/blob/a0c1777ae86d04e46cb42eb3af69ca74ae5d79e2/index.js#L11-L14)
    {
      name: 'alias',
      setup({ onResolve, resolve }) {
        onResolve({ filter: /^prompts$/, namespace: 'file' }, async ({ importer, resolveDir }) => {
          // we can always use non-transpiled code since we support 14.16.0+
          const result = await resolve('prompts/lib/index.js', {
            importer,
            resolveDir,
            kind: 'import-statement'
          })
          return result
        })
      }
    },
    // 生成開源項目的 LICENSE 文件,主要分為2部分,一部分是 create-vue 自己的 LICENSE,一部分是項目所使用依賴的 LICENSE
    esbuildPluginLicense({
      thirdParty: {
        includePrivate: false,
        output: {
          file: 'LICENSE',
          template(allDependencies) {
            // There's a bug in the plugin that it also includes the `create-vue` package itself
            const dependencies = allDependencies.filter((d) => d.packageJson.name !== 'create-vue')
            const licenseText =
              `# create-vue core license\n\n` +
              `create-vue is released under the MIT license:\n\n` +
              CORE_LICENSE +
              `\n## Licenses of bundled dependencies\n\n` +
              `The published create-vue artifact additionally contains code with the following licenses:\n` +
              [...new Set(dependencies.map((dependency) => dependency.packageJson.license))].join(
                ', '
              ) +
              '\n\n' +
              `## Bundled dependencies\n\n` +
              dependencies
                .map((dependency) => {
                  return (
                    `## ${dependency.packageJson.name}\n\n` +
                    `License: ${dependency.packageJson.license}\n` +
                    `By: ${dependency.packageJson.author.name}\n` +
                    `Repository: ${dependency.packageJson.repository.url}\n\n` +
                    dependency.licenseText
                      .split('\n')
                      .map((line) => (line ? `> ${line}` : '>'))
                      .join('\n')
                  )
                })
                .join('\n\n')

            return licenseText
          }
        }
      }
    })
  ]
})

下面分2方面解析一下以上代碼,1. esbuild.build api 講解;2. 連個具體的 plugin 功能講解;

(1) esbuild.build Api ??

這部分只要還是翻譯官方文檔了,因為核心關(guān)注點不在這塊,屬于快速科普,達到能理解的目的即可。

?? esbuild 官方文檔

Esbuild 支持 JavaScriptGoLang 2種語言。其中 JavaScript Api 有異步和同步兩種類型。建議使用異步API,因為它適用于所有環(huán)境,并且速度更快、功能更強大。同步 Api 僅在 node 環(huán)境下工作,但在某些情況下也是必要的。

同步API調(diào)用使用promise返回其結(jié)果。需要注意的是,由于使用了 importawait關(guān)鍵字,需要在 node環(huán)境 中使用.mjs文件擴展名。

擴展資料:為什么使用了這2個關(guān)鍵字,在node環(huán)境中需要使用 .mjs 擴展名?

?? node.js如何處理esm模塊

JavaScript 語言有兩種格式的模塊,一種是 ES6 模塊,簡稱 ESM;另一種是 Node.js 專用的 CommonJS 模塊,簡稱 CJS。這兩種模塊不兼容。

ES6 模塊和 CommonJS 模塊有很大的差異。語法上面,CommonJS 模塊使用require()加載和module.exports輸出,ES6 模塊使用importexport。用法上面,require()是同步加載,后面的代碼必須等待這個命令執(zhí)行完,才會執(zhí)行。import命令則是異步加載,或者更準(zhǔn)確地說,ES6 模塊有一個獨立的靜態(tài)解析階段,依賴關(guān)系的分析是在那個階段完成的,最底層的模塊第一個執(zhí)行。

Node.js 要求 ES6 模塊采用.mjs后綴文件名。也就是說,只要腳本文件里面使用import或者export命令,那么就必須采用.mjs后綴名。Node.js 遇到.mjs文件,就認(rèn)為它是 ES6 模塊,默認(rèn)啟用嚴(yán)格模式,不必在每個模塊文件頂部指定"use strict"。

import * as esbuild from 'esbuild'

let result = await esbuild.build({
  bundle: true, // 是否打包
  entryPoints: ['index.ts'], // 入口
  outfile: 'outfile.cjs', // 出口
  format: 'cjs', // 輸出格式 commonJS
  platform: 'node', // 平臺
  target: 'node14',
	plugin: []
})
  • bundle

打包文件意味著將任何導(dǎo)入的依賴項內(nèi)聯(lián)到文件本身。這個過程是遞歸的,因此依賴項的依賴項也將內(nèi)聯(lián)。默認(rèn)情況下,esbuild不會打包輸入文件,必須顯式啟用。如上示例,傳 true 表示顯式啟用。

  • entryPoints

入口文件,表示從那個文件開始進行打包。entryPoints 是一個數(shù)組,支持多個入口文件,多個入口文件,會生成多個輸出文件。因此,如果是相對簡單的應(yīng)用,建議將所有文件統(tǒng)一導(dǎo)入到一個文件,再將該文件作為入口進行打包。如果導(dǎo)入多個入口文件,則必須指定一個 outdir 目錄。

  • outfile

設(shè)置輸出文件名。該屬性僅在從單個入口文件打包時有效。如果有多個入口文件,則必須指定outdir 目錄。

  • format

打包輸出的文件格式,有 iife 、cjs 、esm 三種形式。

  • platform

默認(rèn)情況下,esbuild的 bundler 被配置為生成用于瀏覽器的代碼。如果您的bundle代碼打算在node中運行,您應(yīng)該將平臺設(shè)置為node.

  • target

為生成的JavaScript 或 CSS 代碼設(shè)置目標(biāo)環(huán)境。它告訴esbuild將對這些環(huán)境來說太新的JavaScript語法轉(zhuǎn)換為在這些環(huán)境中能正常運行的的舊JavaScript 語法。

  • plugin

plugin API 支持用戶將代碼注入到構(gòu)建過程的各個部分。與其他API不同,它無法從命令行輸入配置。必須編寫JavaScript或Go代碼才能使用插件API。

一個插件是一個包含 name 屬性和 setup 方法的對象。通過一個數(shù)組傳遞給 build Api. 每次 build Api 調(diào)用都會運行一次setup函數(shù)。

onResolve

使用onResolve添加的回調(diào)將在esbuild.build 每個模塊的導(dǎo)入路徑上運行。回調(diào)可以自定義 esbuild 如何進行路徑解析。例如,它可以攔截導(dǎo)入路徑并將其重定向到其他地方。它也可以將路徑標(biāo)記為外部路徑。以下是一個示例:

import * as esbuild from 'esbuild'
import path from 'node:path'

let exampleOnResolvePlugin = {
  name: 'example',
  setup(build) {
    // Redirect all paths starting with "images/" to "./public/images/"
    build.onResolve({ filter: /^images\// }, args => {
      return { path: path.join(args.resolveDir, 'public', args.path) }
    })

    // Mark all paths starting with "http://" or "https://" as external
    build.onResolve({ filter: /^https?:\/\// }, args => {
      return { path: args.path, external: true }
    })
  },
}

await esbuild.build({
  entryPoints: ['app.js'],
  bundle: true,
  outfile: 'out.js',
  plugins: [exampleOnResolvePlugin],
  loader: { '.png': 'binary' },
})

(2) plugins ??

esbuildplugin 的用法在上一部分已做出詳解。這里我們來分析 build.mjs 中的2個插件的具體作用。

首先是第一個插件:對 prompts 的打包處理。

{
  name: 'alias',
    setup({ onResolve, resolve }) {
    // 使用onResolve添加的回調(diào)將在esbuild構(gòu)建的每個模塊中的每個導(dǎo)入路徑上運行?;卣{(diào)可以自定義esbuild如何進行路徑解析
    onResolve({ filter: /^prompts$/, namespace: 'file' }, async ({ importer, resolveDir }) => {
      // we can always use non-transpiled code since we support 14.16.0+
      const result = await resolve('prompts/lib/index.js', {
        importer,
        resolveDir,
        kind: 'import-statement'
      })
      console.log('esbuild-prompts-result', result)
      return result
    })
  }
},

這段代碼通過插件攔截(或者說捕獲) prompts 的路徑導(dǎo)入,并將其更改為從 prompts/lib/index.js 導(dǎo)入(通常情況下默認(rèn)從根目錄下的index.js導(dǎo)入)。

因為一直搞不明白這個插件的具體作用,所以通過手動注釋該插件的方法,重新進行 build 操作,生成未使用此 plugin處理 的 outfile.cjs 文件,并運行此文件,執(zhí)行腳手架搭建工程。執(zhí)行結(jié)果顯示發(fā)現(xiàn)并未對腳手架的功能造成任何影響。

接著,將包含plugin 和 不含 plugin 的輸出文件進行比對,發(fā)現(xiàn),兩個文件的代碼大部分相同,僅有的差別在于添加插件的 outfile.cjs 文件中的 prompts 的導(dǎo)入路徑僅有 prompts/lib/..., 而未添加插件的版本,outfile.cjs文件中同時包含 2 份相同的prompts代碼實現(xiàn)(導(dǎo)入路徑分別為 prompts/lib/...prompts/dist/...)。最初對此感到困惑,為何會多出一份,作用是什么。

后來揣測此插件僅是用來做打包體積優(yōu)化的,并無其他用意,畢竟2個版本文件大小相差近一半。

outfile.cjs 體積
outfile-no-alias.cjs 233kb
outfile-with-alia.cjs 143kb

但是畢竟水平有限,不能完全確定,推測如不得到證實,始終心有不甘。后來想到,或許可以找到此文件的提交記錄,看作者提交的意圖是啥(畢竟咱也不認(rèn)識豪群大佬,不能直接請教他)。果不其然,猜想得到證實,以下是該部分代碼的提交記錄:
perf: exclude transpiled prompts code

以上提交來自一個社區(qū)貢獻者,大意是,既然這個包僅支持 node > 14.6 的版本,那么轉(zhuǎn)譯的 prompts 代碼必然不會被用到,也就可以在打包時排除掉。

看到這里,自然想到去扒開 prompts 庫看看,到底咋回事。

恍然大悟,原來如此。prompts 為了兼容低版本的 node 環(huán)境,準(zhǔn)備了 2 份庫文件,其中在 dist 目錄下的是一份轉(zhuǎn)譯后的版本。而在 esbuild 進行依賴打包時,遞歸的解析依賴,所以此處會將2個版本的 prompts 都進行打包。但在這個場景下,create-vue 的打包條件已經(jīng)限制在 node > 14, 也即是說 /dist/.. 下的版本永遠不會被使用,這個包完全沒有必要打進去。所以,這個插件在依賴解析時,直接從 /lib/... 下導(dǎo)入,大大減小了輸出包的體積。

至此,我之前的猜想也可到印證,內(nèi)心陰霾一掃而空,開心。??

接下來是第二個插件:esbuildPluginLicense

插件GitHub地址:esbuildPluginLicense

簡介:License generation tool similar to https://github.com/mjeanroy/rollup-plugin-license

用法:

export const defaultOptions: DeepRequired<Options> = {
  banner: `/*! <%= pkg.name %> v<%= pkg.version %> | <%= pkg.license %> */`,
  thirdParty: {
    includePrivate: false,
    output: {
      file: 'dependencies.txt',
      // Template function that can be defined to customize report output
      template(dependencies) {
        return dependencies.map((dependency) => `${dependency.packageJson.name}:${dependency.packageJson.version} -- ${dependency.packageJson.license}`).join('\n');
      },
    }
  }
} as const

這個插件主要是用來生成開源項目 License 的。

點開 croate-vue 的 License 文件,發(fā)現(xiàn)原來是這里生成的,牛逼啊。??

這里的實現(xiàn)主要是生成 License 內(nèi)容的操作,理解起來不難,這里需要注意的一點是,croate-vue 的 License 是將它所依賴的三方庫的 License 都帶上了,不僅僅是它自己的 License 內(nèi)容。這充分體現(xiàn)了作者對三方庫作者,對知識產(chǎn)權(quán)的尊重,值得深思和學(xué)習(xí)。

2. snapshot ??

接下來是快照功能,快照功能主要是將前文提到的各種配置進行排列組合,然后生成全部類型的 vue 腳手架工程,并將工程保存在 playground 文件夾中, (可能是為了后面做e2e測試的)。如下是官方倉庫中的 playground 目錄:

如圖所示,這里playground 其實對應(yīng)了一個倉庫,名為 create-vue-template, 里面是各種版本的模板工程快照。

在下一個部分 prepublish 模塊將會講到快照發(fā)布的內(nèi)容,這里先擱置,先來看這部分功能的實現(xiàn)。

// snapshot.mjs
// 在 `zx` 腳本文件頂部添加事務(wù)聲明,之后可以無需導(dǎo)入直接調(diào)用 zx 的各類函數(shù)。
#!/usr/bin/env zx
import 'zx/globals' // 此處顯式引入是為了獲得更好的 vscode 輔助編碼支持,也可不導(dǎo)入

$.verbose = false // 關(guān)閉詳細輸出模式

// 檢測包管理工具
if (!/pnpm/.test(process.env.npm_config_user_agent ?? ''))
  throw new Error("Please use pnpm ('pnpm run snapshot') to generate snapshots!")

const featureFlags = ['typescript', 'jsx', 'router', 'pinia', 'vitest', 'cypress', 'playwright']
const featureFlagsDenylist = [['cypress', 'playwright']]

// The following code & comments are generated by GitHub CoPilot.
function fullCombination(arr) {
  const combinations = []

  // for an array of 5 elements, there are 2^5 - 1= 31 combinations
  // (excluding the empty combination)
  // equivalent to the following:
  // [0, 0, 0, 0, 1] ... [1, 1, 1, 1, 1]
  // We can represent the combinations as a binary number
  // where each digit represents a flag
  // and the number is the index of the flag
  // e.g.
  // [0, 0, 0, 0, 1] = 0b0001
  // [1, 1, 1, 1, 1] = 0b1111

  // Note we need to exclude the empty combination in our case
  for (let i = 1; i < 1 << arr.length; i++) {
    const combination = []
    for (let j = 0; j < arr.length; j++) {
      if (i & (1 << j)) {
        combination.push(arr[j])
      }
    }
    combinations.push(combination)
  }
  return combinations
}

let flagCombinations = fullCombination(featureFlags)
flagCombinations.push(['default'])

// Filter out combinations that are not allowed
flagCombinations = flagCombinations.filter(
  (combination) =>
    !featureFlagsDenylist.some((denylist) => denylist.every((flag) => combination.includes(flag)))
)

// `--with-tests` are equivalent of `--vitest --cypress`
// Previously it means `--cypress` without `--vitest`.
// Here we generate the snapshots only for the sake of easier comparison with older templates.
// They may be removed in later releases.
const withTestsFlags = fullCombination(['typescript', 'jsx', 'router', 'pinia']).map((args) => [
  ...args,
  'with-tests'
])
withTestsFlags.push(['with-tests'])

flagCombinations.push(...withTestsFlags)

const playgroundDir = path.resolve(__dirname, '../playground/')
const bin = path.posix.relative('../playground/', '../outfile.cjs')

// 在playground目錄下依次執(zhí)行各種不同組合的指令,生成所有可能的模板的快照
cd(playgroundDir)
for (const flags of flagCombinations) {
  const projectName = flags.join('-')

  console.log(`Removing previously generated project ${projectName}`)
  await $`rm -rf ${projectName}`

  console.log(`Creating project ${projectName}`)
  await $`node ${[bin, projectName, ...flags.map((flag) => `--${flag}`), '--force']}`
}

開始之前,先介紹一下 zx 這個庫。

通常,我們在 bash ,cmd 等終端中輸入指令,執(zhí)行各類命令。但是,如果我們希望執(zhí)行特別復(fù)雜的腳本時,在終端中直接輸入的方式總顯的捉襟見肘?;?JavaScript 等語言編寫復(fù)雜的腳本文件是一個不錯的選擇.

zx 庫則為使用 JavaScript 語言編寫腳本實現(xiàn)了很好的封裝,zx 圍繞 child_process 提供了許多好用的功能,能幫助我們更方便的編寫腳本。

基本用法:

為了在js文件中使用頂級 await,需要將文件命名為 .mjs 后綴類型(這個在 esbuild Api 介紹部分已簡單介紹。)

需在在 zx 腳本文件頂部添加如下事務(wù):

#!/usr/bin/env zx

$, cd, fetch, 等所有的功能都可以直接使用,無需導(dǎo)入。或者可以像下面這樣顯式導(dǎo)入,這樣可以獲得更好的VS Code 自動代碼提示支持。

 import 'zx/globals'
  • $ 命令

使用 $ 命令執(zhí)行指令,返回一個 ProcessPromise。

let name = 'foo & bar'
await $`mkdir ${name}`

如果需要,也可以傳遞一個參數(shù)數(shù)組:

let flags = [
  '--oneline',
  '--decorate',
  '--color',
]
await $`git log ${flags}`

如果執(zhí)行的程序返回退出代碼(exit 1),則將拋出一個ProcessOutput。

try {
  await $`exit 1`
} catch (p) {
  console.log(`Exit code: ${p.exitCode}`)
  console.log(`Error: ${p.stderr}`)
}
  • $.verbose

指定輸出詳細程度,默認(rèn)為 true, 詳細輸出。在詳細模式下,zx打印所有已執(zhí)行的命令及其輸出。

  • cd()方法

更改當(dāng)前工作目錄。

cd('/tmp')
await $`pwd` // => /tmp
  • stdin()

獲取轉(zhuǎn)化為字符串的stdin內(nèi)容。

let content = JSON.parse(await stdin())

以上是一些 zx 的基本用法, 下面我們來分析下 snapshot 實現(xiàn)的功能。

#!/usr/bin/env zx
import 'zx/globals' // 此處顯式引入是為了獲得更好的 vscode 輔助編碼支持,也可不導(dǎo)入
$.verbose = false // 關(guān)閉詳細輸出模式

JavaScript 文件頂部添加 #!/usr/bin/env zx, 表示一個事務(wù)聲明,添加之后可以無需導(dǎo)入直接調(diào)用 zx 的各類函數(shù)。

import 'zx/globals' 顯式導(dǎo)入其實不是必要的, 但是導(dǎo)入后可以獲得更好的 vscode 支持。

$.verbose 屬性表示命令執(zhí)行輸出信息詳細程度,為 true 表示詳細輸出。

// The following code & comments are generated by GitHub CoPilot.
function fullCombination(arr) {
  const combinations = []

  // for an array of 5 elements, there are 2^5 - 1= 31 combinations
  // (excluding the empty combination)
  // equivalent to the following:
  // [0, 0, 0, 0, 1] ... [1, 1, 1, 1, 1]
  // We can represent the combinations as a binary number
  // where each digit represents a flag
  // and the number is the index of the flag
  // e.g.
  // [0, 0, 0, 0, 1] = 0b0001
  // [1, 1, 1, 1, 1] = 0b1111

  // Note we need to exclude the empty combination in our case
  for (let i = 1; i < 1 << arr.length; i++) { // i < 32, 1-31
    const combination = []
    for (let j = 0; j < arr.length; j++) {
      if (i & (1 << j)) {
        combination.push(arr[j])
      }
    }
    combinations.push(combination)
  }
  return combinations
}

<< 表示左移運算,如 1<<5 表示將二進制數(shù) 00000001 左移5位,變?yōu)?00100000, 即 32.

&是位與運算符。它對兩個操作數(shù)的每一位執(zhí)行邏輯與操作,并返回一個新的數(shù)值。

這段代碼實現(xiàn)了一個函數(shù)fullCombination,它接受一個數(shù)組作為參數(shù),并返回該數(shù)組的所有非空子集的組合。

代碼的核心思想是使用二進制表示法來表示組合。對于給定的數(shù)組,假設(shè)長度為n,那么總共會有2^n - 1個組合(不包括空組合)。

代碼使用兩個嵌套的循環(huán)來生成組合。外層循環(huán)從1到2^n - 1,表示所有可能的組合的二進制表示。內(nèi)層循環(huán)遍歷數(shù)組的每個元素,通過位運算來判斷該元素是否應(yīng)該包含在當(dāng)前組合中。具體來說,代碼使用位運算符&來檢查二進制表示中的每一位是否為1,如果是,則將對應(yīng)的數(shù)組元素添加到當(dāng)前組合中。

最后,生成的組合存儲在一個數(shù)組combinations中,并作為函數(shù)的返回值返回。

const featureFlags = ['typescript', 'jsx', 'router', 'pinia', 'vitest', 'cypress', 'playwright']
const featureFlagsDenylist = [['cypress', 'playwright']]

let flagCombinations = fullCombination(featureFlags)
flagCombinations.push(['default'])

// Filter out combinations that are not allowed
flagCombinations = flagCombinations.filter(
  (combination) =>
    !featureFlagsDenylist.some((denylist) => denylist.every((flag) => combination.includes(flag)))
)
// `--with-tests` are equivalent of `--vitest --cypress`
// Previously it means `--cypress` without `--vitest`.
// Here we generate the snapshots only for the sake of easier comparison with older templates.
// They may be removed in later releases.
const withTestsFlags = fullCombination(['typescript', 'jsx', 'router', 'pinia']).map((args) => [
  ...args,
  'with-tests'
])
withTestsFlags.push(['with-tests'])

flagCombinations.push(...withTestsFlags)

這段代碼調(diào)用上面的fullCombination方法,對上面的多種指令進行排列組合,最終結(jié)果有112種情況。

const featureFlags = ['typescript', 'jsx', 'router', 'pinia', 'vitest', 'cypress', 'playwright']
const featureFlagsDenylist = [['cypress', 'playwright']]

let flagCombinations = fullCombination(featureFlags)
flagCombinations.push(['default'])
// 前一部分共 128個組合

// Filter out combinations that are not allowed
flagCombinations = flagCombinations.filter(
  (combination) =>
    !featureFlagsDenylist.some((denylist) => denylist.every((flag) => combination.includes(flag)))
)
// 過濾掉同時含有'cypress'和 'playwright'的組合 32 個,變?yōu)?96個
// `--with-tests` are equivalent of `--vitest --cypress`
// Previously it means `--cypress` without `--vitest`.
// Here we generate the snapshots only for the sake of easier comparison with older templates.
// They may be removed in later releases.
const withTestsFlags = fullCombination(['typescript', 'jsx', 'router', 'pinia']).map((args) => [
  ...args,
  'with-tests'
])
withTestsFlags.push(['with-tests'])
flagCombinations.push(...withTestsFlags)
// 96個又添加16個,共112個
const playgroundDir = path.resolve(__dirname, '../playground/')
const bin = path.posix.relative('../playground/', '../outfile.cjs')

cd(playgroundDir)
for (const flags of flagCombinations) {
  const projectName = flags.join('-')

  console.log(`Removing previously generated project ${projectName}`)
  await $`rm -rf ${projectName}`

  console.log(`Creating project ${projectName}`)
  await $`node ${[bin, projectName, ...flags.map((flag) => `--${flag}`), '--force']}`
}

最后一段,首先清空playground 目錄,移除之前的版本,然后根據(jù)前面計算出的112種組合的配置生成 112 種創(chuàng)建腳手架工程指令,并使用 zx 執(zhí)行,最終得到112種快照。

3. prepublish ??

#!/usr/bin/env zx
import 'zx/globals'

await $`pnpm build`
await $`pnpm snapshot`

let { version } = JSON.parse(await fs.readFile('./package.json'))

const playgroundDir = path.resolve(__dirname, '../playground/')
cd(playgroundDir)

await $`pnpm install`
await $`git add -A .`
try {
  await $`git commit -m "version ${version} snapshot"`
} catch (e) {
  if (!e.stdout.includes('nothing to commit')) {
    throw e
  }
}
// 前一部分,運行指令進行構(gòu)建,快照生成,并將快照推到 playground 子倉庫中
// ----------------------------------------------------------

await $`git tag -m "v${version}" v${version}`
await $`git push --follow-tags`

const projectRoot = path.resolve(__dirname, '../')
cd(projectRoot)
await $`git add playground`
await $`git commit -m 'chore: update snapshot' --allow-empty`
await $`git push --follow-tags`
// 后一部分則將整體的更改推送到主倉庫,其中包含添加版本號和tag的操作
// ----------------------------------------------------------

這部分主要功能是依次執(zhí)行打包和快照功能,然后將新的版本代碼推送到倉庫。

這里主要注意的一點是,playground 目錄是作為一個 gitmodules 存儲在另一個倉庫的。

以上代碼分為2部份,前一部分提交和推送快照子倉庫的代碼,后一部分則是推送主倉庫的代碼。

[submodule "playground"]
	path = playground
	url = https://github.com/vuejs/create-vue-templates.git

這里是 .gitmodules 文件中關(guān)于子 git 倉庫的配置。對應(yīng)倉庫內(nèi)容如下。

結(jié)束,也是開始

歷時近2月,我的第一篇系統(tǒng)性源碼解讀文章終于接近尾聲。

幾個月前公司說要寫一個內(nèi)部腳手架,我從 vue 官方倉庫下載了 create-vue 腳手架的代碼。最開始只是大致閱讀了其主要首先流程,便已對其簡潔的實現(xiàn)驚嘆不已。任何一個簡單的企業(yè)應(yīng)用,僅論代碼量都比這個項目要多的多。當(dāng)時雖能大致理解其實現(xiàn)過程,但對其中的很多細節(jié)問題都一無所知,如 "為什么執(zhí)行 npm create vue 命令, create-vue 工具就會執(zhí)行?","腳手架的終端交互邏輯是怎么實現(xiàn)的?","一個完整的 vue 工程是怎么產(chǎn)生的?",“項目打包時,添加的2個插件的作用是啥?”等等,我都搞不清楚,或者說只有很模糊的認(rèn)識。最初,我也簡單零散的記錄了一些學(xué)習(xí)筆記,但始終不曾深入進去,直到最近的一個契機,促使我開始思考和深入。

從事前端開發(fā)工作已滿三年,在公司負(fù)責(zé)一款產(chǎn)品的前端開發(fā)工作。我們公司的工作安排某種程度講,其實不太利于員工的能力提升,只要你穩(wěn)定參與一個項目的工作,就會一直干這個項目。我從三年前畢業(yè)入職到公司開始,始終從事同一個產(chǎn)品,同一個技術(shù)棧的相關(guān)工作。期間我基于本職工作內(nèi)容,從各方面尋求一些技術(shù)成長。我的產(chǎn)品是一個很老的項目,基于 vue2.1 的技術(shù)方案,最初,部門只要有新人加入,還無具體工作分配時,都會參與到這個項目中進行一些開發(fā)工作,所以代碼是有一些混亂的。三年時間,我通過自發(fā)的對老舊代碼進行重構(gòu),推動實現(xiàn) vue2vue3技術(shù)升級,mvc 老舊項目前后端分離,攻克各類技術(shù)難點問題,自建部門UI組件庫等工作的鍛煉,技術(shù)和能力得到了一些進步和提升。但越來越感覺在有限的技術(shù)圈子中,成長漸行漸緩了。

加之,最近公司的一些工作模式和流程的調(diào)整,讓我越來越感到技術(shù)前景上的灰暗。在這種工作模式下,個人深感技術(shù)熱情逐漸湮滅,技術(shù)上限提前封頂,不禁悲從中來,同時也危從中來。

當(dāng)前階段,迷茫是肯定迷茫的,但是依然要努力撥開迷霧,找到前路。最近閱讀了一些關(guān)于生活,學(xué)習(xí)和認(rèn)知方面的書籍,對我內(nèi)心有一些觸動,其中有3個觀點我目前正在踐行:

  1. 簡化自己;
  2. 做最重要的事;
  3. 消除模糊,找到確定性;

以前也寫過一些技術(shù)文章,近幾年很多文章都半途而廢了,都不能說草草結(jié)尾,很多都沒有寫到最后就擱置了。

近2月,我舍棄了大部分分散精力的活動,物品,業(yè)余時間集中精力準(zhǔn)備篇技術(shù)分析,力求做到分析清楚其中的每個細節(jié),消除模糊,最終完成了,沒有放棄。這篇文章,也算是我踐行新的生活方式的一個起點。

往后一年時間,我將繼續(xù)堅持創(chuàng)作。這里,即是結(jié)束,也是開始!

個人技術(shù)能力和寫作能力尚存在很多缺陷,文章質(zhì)量不高,也許存在一些謬誤,希望大家不吝指正和指導(dǎo)!

最后,友好交流,勿用惡語,共同進步!文章來源地址http://www.zghlxwxcb.cn/news/detail-706087.html

到了這里,關(guān)于【源碼】Vue.js 官方腳手架 create-vue 是怎么實現(xiàn)的?的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!

本文來自互聯(lián)網(wǎng)用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務(wù),不擁有所有權(quán),不承擔(dān)相關(guān)法律責(zé)任。如若轉(zhuǎn)載,請注明出處: 如若內(nèi)容造成侵權(quán)/違法違規(guī)/事實不符,請點擊違法舉報進行投訴反饋,一經(jīng)查實,立即刪除!

領(lǐng)支付寶紅包贊助服務(wù)器費用

相關(guān)文章

  • 【Vue H5項目實戰(zhàn)】從0到1的自助點餐系統(tǒng)—— 搭建腳手架(Vue3.2 + Vite + TS + Vant + Pinia + Node.js)

    【Vue H5項目實戰(zhàn)】從0到1的自助點餐系統(tǒng)—— 搭建腳手架(Vue3.2 + Vite + TS + Vant + Pinia + Node.js)

    H5 項目基于 Web 技術(shù),可以在智能手機、平板電腦等移動設(shè)備上的瀏覽器中運行,無需下載和安裝任何應(yīng)用程序,且H5 項目的代碼和資源可以集中在服務(wù)器端進行管理,只需更新服務(wù)器上的代碼,即可讓所有顧客訪問到最新的系統(tǒng)版本。 本系列將以肯德基自助點餐頁面為模板

    2024年02月13日
    瀏覽(133)
  • Vue(Vue腳手架)

    Vue(Vue腳手架)

    Vue官方提供腳手架平臺選擇最新版本: 可以相加兼容的標(biāo)準(zhǔn)化開發(fā)工具(開發(fā)平臺) 禁止:最新的開發(fā)技術(shù)版本和比較舊版本的開發(fā)平臺 ? Vue CLI ??? Vue.js 開發(fā)的標(biāo)準(zhǔn)工具 https://cli.vuejs.org/zh/ c:cmmand l:line i:interface 命令行接口工具? ?在cmd中查看vue是否存在cli ?全局安

    2024年02月01日
    瀏覽(20)
  • 使用Vue腳手架

    使用Vue腳手架

    (193條消息) 第 3 章 使用 Vue 腳手架_qq_40832034的博客-CSDN博客 說明 1.Vue腳手架是Vue官方提供的標(biāo)準(zhǔn)化開發(fā)工具(開發(fā)平臺) 2.最新的版本是4.x 3.文檔Vue CLI腳手架(命令行接口) 具體步驟 1.如果下載緩慢請配置npm淘寶鏡像 npm config set registry http://registry.npm.taobao.org 2.全局安裝 @v

    2024年02月13日
    瀏覽(35)
  • Vue 腳手架

    ├── node_modules ├── public │ ├── favicon.ico: 頁簽圖標(biāo) │ └── index.html: 主頁面 ├── src │ ├── assets: 存放靜態(tài)資源 │ │ └── logo.png │ │── component: 存放組件 │ │ └── HelloWorld.vue │ │── App.vue: 匯總所有組件 │ │── main.js: 入口文件 ├── .gi

    2024年03月24日
    瀏覽(20)
  • vue腳手架文件說明

    vue腳手架文件說明

    node_modules 都是下載的第三方包 public/index.html 瀏覽器運行的網(wǎng)頁 src/main.js webpack打包的入口 src/APP.vue Vue頁面入口 package.json 依賴包列表文件

    2024年02月15日
    瀏覽(33)
  • Vue腳手架搭建項目

    Vue腳手架搭建項目

    一、 安裝Node.js (一) 注意事項 1. 注意電腦系統(tǒng)版本以及位數(shù),按照自己電腦的環(huán)境下載相應(yīng)的Node.js安裝包 2. 確定運行項目的Node.js版本和npm版本,避免后期因為版本不同而產(chǎn)生的一些差異問題 3. 在官網(wǎng)下載Node安裝包時請下載穩(wěn)定版(或不同版本的穩(wěn)定版),正確區(qū)分穩(wěn)定版

    2024年02月09日
    瀏覽(37)
  • 如何搭建vue腳手架

    使用 create-vue 腳手架創(chuàng)建項目 create-vue參考地址:GitHub - vuejs/create-vue: ??? The recommended way to start a Vite-powered Vue project 步驟: 執(zhí)行創(chuàng)建命令 2.選擇項目依賴類容 安裝:項目開發(fā)需要的一些插件 必裝: Vue Language Features (Volar) ?vue3語法支持 TypeScript Vue Plugin (Volar) ?vue3中更好的

    2023年04月14日
    瀏覽(26)
  • vue腳手架創(chuàng)建項目

    vue腳手架創(chuàng)建項目

    npm install -g @vue/cli 如果報錯可以嘗試使用cnpm vue -V vue create 項目名稱 輸入y 上下選中選項 Manually select features (自由選擇),回車 vue 版本的選擇 其他按需要選擇

    2024年02月05日
    瀏覽(30)
  • 使用Vue腳手架2

    使用Vue腳手架2

    ref屬性 src/components/SchoolName.vue ? src/App.vue ? props配置項 src/App.vue src/components/StudentName.vue ? 注意:當(dāng)props中與當(dāng)前組件配置同名時, props中的配置優(yōu)先級高于當(dāng)前組件? mixin混入 1. 組件和混入對象含有同名選項 時,這些選項將以恰當(dāng)?shù)姆绞竭M行“合并”,在發(fā)生沖突時以 組件

    2024年02月12日
    瀏覽(18)
  • VUE2 腳手架搭建

    VUE2 腳手架搭建

    M : Model 模型層(業(yè)務(wù)邏輯層)主要包含 JS 代碼,用于管理業(yè)務(wù)邏輯的實現(xiàn) V : View 視圖層 主要包含 HTML/CSS 代碼,用于管理 UI 的展示 VM : ViewModel (視圖模型層)用于將 data 與視圖層的 Dom 進行動態(tài)綁定 ①腳手架環(huán)境安裝 制作web項目,從小作坊狀態(tài)轉(zhuǎn)向工程化開發(fā)的狀態(tài)

    2024年02月09日
    瀏覽(101)

覺得文章有用就打賞一下文章作者

支付寶掃一掃打賞

博客贊助

微信掃一掃打賞

請作者喝杯咖啡吧~博客贊助

支付寶掃一掃領(lǐng)取紅包,優(yōu)惠每天領(lǐng)

二維碼1

領(lǐng)取紅包

二維碼2

領(lǐng)紅包