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 create
、pnpm create
,這三個命令的大致流程都是類似的:
-
1. 解析命令行參數,得到 初始化器名稱
initerName
和項目名稱projectName
-
2. 解析
initerName
,確定對應的依賴包名稱和版本號 -
3. 根據依賴包名稱和版本號下載安裝初始化器
-
4. 執行初始化器的
create
方法 -
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. 通過
deref
解析cmd
對應的實際命令command
-
2. 根據
command
加載lib/commands
中的指定文件,得到命令對應的 構造函數 -
3. 傳入當前的
npm
實例作爲構造函數參數得到命令實例 -
4. 調用命令實例的
cmdExec(args)
執行命令的真實邏輯
可見,commands
中包含了所有的 NPM CLI
的可用命令,例如 version
、install
、help
等,但是這個目錄中是不存在 create
命令的,在 deref
過程中發現 npm 會通過 utils/cmd-list.js
解析實際命令。該文件中定義了一個 aliases
別名對象,而 create
、innit
實際對應的命令都是 init
命令。
所以,npm create
和 npm innit
命令最終的實際執行命令都是 npm init
,只需要找到 init
的文件即可。
init 命令
根據上文可以知道,init.js 文件內部應該提供的是一個 class
,進入文件後可發現該這個類會繼承一個 BaseCommand
,並且 constructor
構造函數也是在 BaseCommand
中。
進入 BaseCommand
,構造函數中只是在實例中保存了 npm
實例,並提供了 name
、description
等屬性的 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
}
這裏的大致過程如下:
-
• 首先是校驗目標路徑,如果存在的話直接忽略
has argTargetDir
進入下一步判斷 -
• 如果上一步目標路徑不是空則提示是否清空文件夾
-
• 再次判斷輸入的項目名(也就是路徑)是否符合
package.json
的命名規範,不然需要重新輸入 -
• 選擇框架
-
• 如果選擇的框架有對應的變體,需要再次選擇對應的框架變體
最終每一步的選擇結果都是保存到一個 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. 獲取
root
目錄,也就是通過process.cwd()
獲取當前的 工作目錄 並拼接上targetDir
作爲項目的根目錄 -
2. 如果前一步
prompt
中確認了overwrite
參數存在且爲true
,則調用emptyDir
進行清空 -
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
代碼中給出的模板選項,存在以下 框架:
-
•
Vanilla
-
•
Vue
-
•
React
-
•
Preact
-
•
Lit
-
•
Svelte
-
•
Others
並且每個框架都有至少一個 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
增加了一個 feature
:add 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