create-vite 全流程揭祕

前言

在 Vue 3 發佈之際,尤大出於對 Webpack 性能的不滿,發佈了基於 esbuild 的新構建工具 —— Vite,號稱是 下一代前端開發與構建工具

當然,在構建工具有了之後,爲了支持快速構建一個模板項目,vue 團隊也隨之發佈了 Vite 關聯的腳手架 —— create-vite,並且經過這幾年的飛速迭代,如今 create-vite 已經來到了 4.2-beta 版本,並且通過 TypeScript 對其進行了重寫。

現在,就讓我們從利用 create-vite 創建項目開始吧🎉

如何使用

根據官方文檔,通過 create-vite 創建一個空白項目,只需要使用對應的 包管理工具(NPM,Yarn,pNpm) 執行 create 操作即可:

npm create vite@latest
# or
yarn create vite
# or
pnpm create vite

執行後我們需要手動輸入和確認 項目名稱、使用框架、語言類型等,如果有和項目同名的文件夾,還會提示 是否清空已有文件夾。如下圖:

當然,我們也可以直接 將項目名和選擇的模板 作爲參數直接拼接在命令後,例如:

# npm 6.x
npm init vite@latest my-vue-app --template vue

# npm 7+, 需要額外的雙橫線:
npm init vite@latest my-vue-app -- --template vue

# yarn
yarn create vite my-vue-app --template vue

# pnpm
pnpm create vite my-vue-app -- --template vue

當然,這個過程中如果存在同名非空文件夾的話,依然會提示是否清空,此時運行結果如下:

此時就創建完成了,只需要進入該項目目錄並執行 install 命令安裝好項目依賴,即可正常運行。

Create 創建過程

簡單來說,不管是 npm create 還是 yarn createpnpm create,這三個命令的大致流程都是類似的:

  1. 1. 解析命令行參數,得到 初始化器名稱 initerName 和項目名稱 projectName

  2. 2. 解析 initerName,確定對應的依賴包名稱和版本號

  3. 3. 根據依賴包名稱和版本號下載安裝初始化器

  4. 4. 執行初始化器的 create 方法

  5. 5. 成功後打印提示信息,退出進程

我們以 npm create vite@latest 來分析項目的具體創建過程

create 命令

NPM 在之前遷移了一次倉庫,目前倉庫地址爲:npm/cli,通過 package.json 指定的 index.js 可以找到 npm 命令的實際執行是 調用 lib/cli.js 並傳入所有參數 process ,可見核心目錄爲 lib

在 lib/cli.js 中則又是調用 lib/npm.js 的構造函數 Npm 進行實例化得到 npm,並解析出來 process 中的核心命令 cmd,通過 npm.exec(cmd) 執行相關命令。

因爲 npm.js 中代碼較多,所以大致描述一下 npm.exec(cmd) 的過程吧:

  1. 1. 通過 deref 解析 cmd 對應的實際命令 command

  2. 2. 根據 command 加載 lib/commands 中的指定文件,得到命令對應的 構造函數

  3. 3. 傳入當前的 npm 實例作爲構造函數參數得到命令實例

  4. 4. 調用命令實例的 cmdExec(args) 執行命令的真實邏輯

可見,commands 中包含了所有的 NPM CLI 的可用命令,例如 versioninstallhelp 等,但是這個目錄中是不存在 create 命令的,在 deref 過程中發現 npm 會通過 utils/cmd-list.js 解析實際命令。該文件中定義了一個 aliases 別名對象,而 createinnit 實際對應的命令都是 init 命令。

所以,npm create 和 npm innit 命令最終的實際執行命令都是 npm init,只需要找到 init 的文件即可。

init 命令

根據上文可以知道,init.js 文件內部應該提供的是一個 class,進入文件後可發現該這個類會繼承一個 BaseCommand,並且 constructor 構造函數也是在 BaseCommand 中。

進入 BaseCommand,構造函數中只是在實例中保存了 npm 實例,並提供了 namedescription 等屬性的 get 方法,並且 提供了 cmdExec 方法,這個方法中主要是 ** 判斷是否是在 workplace 工作區目錄中執行,是則執行 execWorkspaces,不是則執行 exec()**。這裏我們直接進入 exec

async exec (args) {
  // npm exec style
  if (args.length) {
    return await this.execCreate(args)
  }
  // no args, uses classic init-package-json boilerplate
  await this.template()
}

這裏 execCreate 方法內部其實就是上面所說的 npm create 的過程的實現:

  async execCreate (args, path = process.cwd()) {
    const [initerName, ...otherArgs] = args
    let packageName = initerName

    if (/^@[^/]+$/.test(initerName)) {
      // ...
    } else {
      const req = npa(initerName)
      if (req.type === 'git' && req.hosted) {
        const { user, project } = req.hosted
        packageName = initerName.replace(`${user}/${project}``${user}/create-${project}`)
      } else if (req.registry) {
        packageName = `${req.name.replace(/^(@[^/]+\/)?/, '$1create-')}@${req.rawSpec}`
      } else {
        throw Object.assign(new Error('...'){ code: 'EUNSUPPORTED' })
      }
    }

    const newArgs = [packageName, ...otherArgs]
    const { color } = this.npm.flatOptions
    const { flatOptions, localBin, globalBin } = this.npm
    const output = this.npm.output.bind(this.npm)
    const runPath = path
    const scriptShell = this.npm.config.get('script-shell') || undefined
    const yes = this.npm.config.get('yes')

    await libexec({
      ...flatOptions,
      args: newArgs,
      color,
      localBin,
      globalBin,
      output,
      path,
      runPath,
      scriptShell,
      yes,
    })
  }

這裏的第一步就是 ** 解析 initerName**。

if 判斷中首先是判斷是否是 scoped,也就是是否是以 @ 符號開頭,這裏 vite 走 else 部分。

這裏會將 vite@latest 通過 npm(npm-package-arg) 進行解析,得到開發者名稱 user 和 項目名稱 project,並將其進行修改得到新的包名:vitejs/create-vite,然後調用 libexec 進行安裝和執行。

libexec 與 npm/cli 同一倉庫,入口文件位於 index.js,由於代碼太多,就省略這部分的內容

最終在依賴加載和查找到之後,會通過 runScript 找到這個包的 package.json 中指定的 bash 腳本(bin 命令配置),並執行其 create-* 命令,與 vite 相關的即是 create-vite

create-vite

經過上面的分析,可以確認最終與 vite 相關的命令的起始位置就是 vitejs/create-vite/package.json 中的 create-vite 腳本。

那麼就讓我正式進入 create-vite 項目開始解析這個項目腳手架的執行過程吧~

入口

create-vite 項目目前位於 vitejs 團隊的 vite 項目中,具體地址爲:create-vite。

通過 package.json 中 bin 配置的 create-vite 命令,指定入口文件爲根目錄下的 index.js,當然這裏的引入的是打包後的資源路徑,實際文件爲 src/index.ts

整個文件加上類型定義和分隔行一共 488 行代碼,依賴 Node.js 自帶的 fs 文件模塊、path 路徑處理模塊、url 中的 fileURLToPath url 轉文件路徑模塊,以及外部的 cross-spawn 跨平臺進程庫、minimist 命令解析、prompts 命令行交互、kolorist 命令行彩色文本 四個庫。

然後定義了一系列的變量和函數,最終執行 init().catch(e => console.error(e))

init 初始化函數

整個 init 函數貫穿了模板項目初始化的整個流程,文件內定義的所有方法和參數也都在這個方法中得以執行。我們對其進行拆分再分析每一部分的作用。

1. 路徑分析與項目名稱

在 init 執行的第一步,就是解析項目創建的 dir 位置:

const argv = minimist<{
  t?: string
  template?: string
}>(process.argv.slice(2){ string: ['_'] })

function formatTargetDir(targetDir: string | undefined) {
  return targetDir?.trim().replace(/\/+$/g, '')
}

const defaultTargetDir = 'vite-project'

async function init() {
    const argTargetDir = formatTargetDir(argv._[0])
  let targetDir = argTargetDir || defaultTargetDir
  const getProjectName = () =>
  targetDir === '.' ? path.basename(path.resolve()) : targetDir
}

這裏是從命令行的 第三個參數 開始,將其解析到一個包含鍵名爲 _ 值爲 字符串數組 的對象 argv 中,並且該對象可能包含兩個字符串屬性 t 和 template

然後從 argv._[0] 中讀取第一個參數並去除其最前面的空格和末尾的反斜槓,如果這個參數不存在則使用默認的文件路徑 vite-project

2. 獲取預設模板

這裏就是從 argv 中讀取相關屬性。

const argTemplate = argv.template || argv.t

3. 參數確認

這一步主要是通過 prompts 詢問用戶並確認所用項目名稱、所用框架等。

let result: prompts.Answers<'projectName' | 'overwrite' | 'packageName' | 'framework' | 'variant'>

try {
  result = await prompts(
    [
      {
        type: argTargetDir ? null : 'text',
        name: 'projectName',
        message: reset('Project name:'),
        initial: defaultTargetDir,
        onState: (state) =targetDir = formatTargetDir(state.value) || defaultTargetDir,
      },
      {
        type: () => !fs.existsSync(targetDir) || isEmpty(targetDir) ? null : 'confirm',
        name: 'overwrite',
        message: () ='',
      },
      {
        type: (_, { overwrite }{ overwrite?: boolean }) => null,
        name: 'overwriteChecker',
      },
      {
        type: () =(isValidPackageName(getProjectName()) ? null : 'text'),
        name: 'packageName',
        message: reset('Package name:'),
        initial: () => toValidPackageName(getProjectName()),
        validate: (dir) => isValidPackageName(dir) || 'Invalid package.json name',
      },
      {
        type: argTemplate && TEMPLATES.includes(argTemplate) ? null : 'select',
        name: 'framework',
        message:
          typeof argTemplate === 'string' && !TEMPLATES.includes(argTemplate)
            ? reset(`"${argTemplate}" isn't a valid template. Please choose from below: `)
            : reset('Select a framework:'),
        initial: 0,
        choices: FRAMEWORKS.map((framework) => {
          const frameworkColor = framework.color
          return {
            title: frameworkColor(framework.display || framework.name),
            value: framework,
          }
        }),
      },
      {
        type: (framework: Framework) => framework && framework.variants ? 'select' : null,
        name: 'variant',
        message: reset('Select a variant:'),
        choices: (framework: Framework) =>
          framework.variants.map((variant) => {
            const variantColor = variant.color
            return {
              title: variantColor(variant.display || variant.name),
              value: variant.name,
            }
          }),
      },
    ],
    {
      onCancel: () => {
        throw new Error(red('') + ' Operation cancelled')
      },
    },
  )
} catch (cancelled: any) {
  console.log(cancelled.message)
  return
}

這裏的大致過程如下:

最終每一步的選擇結果都是保存到一個 result 對象中,如果其中有任何一步用戶進行了不合法操作,則會進入 catch 錯誤捕獲,打印 Operation cancelled 並退出創建過程。

然後,會通過結構的形式將 result 中的數據重新用變量進行保存:

const { framework, overwrite, packageName, variant } = result

4. 清空文件夾

這裏涉及以下內容:

const cwd = process.cwd()
const root = path.join(cwd, targetDir)

if (overwrite) {
  emptyDir(root)
} else if (!fs.existsSync(root)) {
  fs.mkdirSync(root, { recursive: true })
}

//// helpers
function emptyDir(dir: string) {
  if (!fs.existsSync(dir)) {
    return
  }
  for (const file of fs.readdirSync(dir)) {
    if (file === '.git') {
      continue
    }
    fs.rmSync(path.resolve(dir, file){ recursive: true, force: true })
  }
}

邏輯如下:

  1. 1. 獲取 root 目錄,也就是通過 process.cwd() 獲取當前的 工作目錄 並拼接上 targetDir 作爲項目的根目錄

  2. 2. 如果前一步 prompt 中確認了 overwrite 參數存在且爲 true,則調用 emptyDir 進行清空

  3. 3. 如果 overwrite 不存在或者爲 false,並且不存在該目錄,則通過 fs.mkdir 創建一個空目錄

emptyDir 函數首先也會判斷目錄是否存在,避免後續邏輯報錯;然後則是遍歷目錄中的所有文件和文件夾,對 非 .git 文件夾 進行 遞歸刪除並且強制刪除只讀文件

5. 模板處理

這一步已經是項目創建的最後一步了,大致內容如下:

// determine template
let template: string = variant || framework?.name || argTemplate
let isReactSwc = false
if (template.includes('-swc')) {
  isReactSwc = true
  template = template.replace('-swc''')
}

const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent)
const pkgManager = pkgInfo ? pkgInfo.name : 'npm'
const isYarn1 = pkgManager === 'yarn' && pkgInfo?.version.startsWith('1.')

const { customCommand } =
  FRAMEWORKS.flatMap((f) => f.variants).find((v) => v.name === template) ?? {}

if (customCommand) {
  const fullCustomCommand = customCommand
    .replace(/^npm create/, `${pkgManager} create`)
    .replace('@latest'() =(isYarn1 ? '' : '@latest'))
    .replace(/^npm exec/, () ={
      if (pkgManager === 'pnpm') {
        return 'pnpm dlx'
      }
      if (pkgManager === 'yarn' && !isYarn1) {
        return 'yarn dlx'
      }
      return 'npm exec'
    })

  const [command, ...args] = fullCustomCommand.split(' ')
  const replacedArgs = args.map((arg) => arg.replace('TARGET_DIR', targetDir))
  const { status } = spawn.sync(command, replacedArgs, { stdio: 'inherit' })
  process.exit(status ?? 0)
}

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

const templateDir = path.resolve(
  fileURLToPath(import.meta.url),
  '../..',
  `template-${template}`,
)

const write = (file: string, content?: string) ={
const targetPath = path.join(root, renameFiles[file] ?? file)
  if (content) {
    fs.writeFileSync(targetPath, content)
  } else {
    copy(path.join(templateDir, file), targetPath)
  }
}

const files = fs.readdirSync(templateDir)
for (const file of files.filter((f) => f !== 'package.json')) {
  write(file)
}

const pkg = JSON.parse(fs.readFileSync(path.join(templateDir, `package.json`)'utf-8'))

pkg.name = packageName || getProjectName()

write('package.json', JSON.stringify(pkg, null, 2) + '\n')

if (isReactSwc) {
    setupReactSwc(root, template.endsWith('-ts'))
}

const cdProjectName = path.relative(cwd, root)
console.log(`\nDone. Now run:\n`)
if (root !== cwd) {
  console.log(`cd ${cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName}`)
}
switch (pkgManager) {
  case 'yarn':
    console.log('  yarn')
    console.log('  yarn dev')
    break
  default:
    console.log(`  ${pkgManager} install`)
    console.log(`  ${pkgManager} run dev`)
    break
  }
console.log()

這裏首先是根據 參數優先級,確定最終選擇的模板 template ,其中優先級順序爲:variant > framework > argTemplate

根據 create-vite 代碼中給出的模板選項,存在以下 框架

並且每個框架都有至少一個 Variant 變種,變種其實也就是 對其他開發語言的基礎支持,最常見的就是對 JS/TS 兩種語言的支持。當然 others 則是一個例外,需要用戶自己指定,後面會進行說明。

SWC

注意這裏在 template 確定之後,還有一個 swc 的處理: isReactSwc

關於 swc(Speedy Web Compiler),官方釋意是:“SWC is an extensible Rust-based platform for the next generation of fast developer tools. SWC can be used for both compilation and bundling. For compilation, it takes JavaScript / TypeScript files using modern JavaScript features and outputs valid code that is supported by all major browsers

翻譯過來也就是:SWC 是基於 Rust 的下一代快速開發工具,可以用於代碼編譯和綁定;在代碼編譯上面,它可以使用現代 JavaScript 功能來處理 JavaScript/TypeScript 代碼文件並輸出適用於所有主流瀏覽器都支持的有效代碼。

在代碼編譯上面,我們可以把它當成與 Babel 同等的代碼處理插件,但是由於其基於 Rust 開發,所以在速度上比 Babel 快很多倍:

SWC 在單線程上比 Babel 快 20 倍,在四核上 快 70 倍

如果用戶選擇的模板具有 swc 配置,則會將 isReactSwc 變量置爲 true,並從 template 中移除 -swc 部分。

pkgManager

隨後緊接着的就是處理 node packages manager 包管理工具。

首先會通過 pkgFromUserAgent 來讀取 用戶代理字符串 中的相關信息,並返回 name 包管理工具名稱、version 包管理工具版本。

然後通過 const pkgManager = pkgInfo ? pkgInfo.name : 'npm' 處理,如果上面的 pkgFromUserAgent 返回爲空則會默認使用 npm

對於 yarn 的話,由於 yarn@1.x 版本不支持在 create 階段指定版本,所以需要一個變量來保存它的版本信息以供後面進行初始化命令的拼接:

const isYarn1 = pkgManager === 'yarn' && pkgInfo?.version.startsWith('1.')

此時,我們已經確定了用戶當前的一個大致的工作環境了,包括工作目錄、包管理工具等。

customCommand

在 v3.1.0 版本中,create-vite 增加了一個 featureadd support for custom init commands (create-vue, Nuxt, and SvelteKit)

這一步在確認了用戶的工作環境之後,就會在用戶選擇的 模板和變種 中提取出對應的 customCommand 自定義命令,然後根據上面確定的環境來 重新處理命令語句

核心邏輯就是:替換默認語句中的包管理工具,指定當前的工作目錄,通過 spawn.sync 啓動一個 子進程 執行腳本,並且設置 stdio: 'inherit' 讓子進程繼承當前進程的輸入輸出,將輸出信息同步輸出在當前進程上。

writeTemplate

在以上步驟結束之後,就進入到 模板項目創建 的過程了,這一步的主要內容就是 複製和創建模板中的對應文件

這部分的 第一步 就是,找到所選 template 對應的模板文件夾 template-${template},例如我們選擇的模板是 vue 變種是 vue-ts,則這裏的模板目錄就是 template-vue-ts

緊接着會定義 write(), files() 兩個函數,然後讀取模板中的 package.json 最爲一個 json 對象,修改其 name 屬性爲用戶輸入的項目名稱 projectName

然後遍歷模板目錄中的文件將其複製到工作目錄中,將新的 pkg.json 轉換成字符串格式重新寫入當前工作目錄中。

如果上面新增的 isReactSwc 爲 true 的話,還會通過 editFile 方法在 vite.config.js/ts 和 package.json 中插入 swc 的相關插件,但是目前 僅支持 react 項目

logInfo

最後,就是通過幾個 console 命令,打印創建成功與使用的命令。

小結

至此,從輸入 npm create vite@latest 到項目創建完成,期間的完整過程就已經解析完了。

命令的第一步就是 npm cli 等相關包管理工具提供的命令行腳手架,從查找 create 命令開始一直到解析參數找到相應的 npm dependence 執行對應腳本,最終進入到 create-vite 的內部邏輯。

在理解了這個過程之後,我們也可以仿照該過程,創建我們自己的 create-xxx 的模板項目創建工具~

並且在項目的編寫過程中,必須仔細處理可能發生的各種異常情況,比如參數缺失、用戶操作錯誤等,保證能完整詳細的提示用戶如何操作或者操作異常發生的位置,同時保證整個流程的順利執行。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ZdZ-cwVzbmc0suLy3HphSQ