create?vite?實例源碼解析
代碼結構
create-vite
的源碼很簡單,只有一個文件,代碼總行數(shù)400
左右,但是實際需要閱讀的代碼大約只有200
行左右,廢話不多說,直接開始吧。
create-vite
的代碼結構非常簡單,直接將index.ts
拉到最底下,發(fā)現(xiàn)只執(zhí)行了一個函數(shù)init()
:
init().catch((e) => { console.error(e) })
我們的故事將從這里開始。
init()
init()
函數(shù)的代碼有點長,但是實際上也不復雜,我們先來看看它最開頭的兩行代碼:
async function init() { const argTargetDir = formatTargetDir(argv._[0]) const argTemplate = argv.template || argv.t }
首先可以看到init
函數(shù)是一個異步函數(shù),最開始的兩行代碼分別獲取了argv._[0]
和argv.template
或者argv.t
;
這個argv
是怎么來的,當然是通過一個解析包來解析的,在頂部有這樣的一段代碼:
const argv = minimist(process.argv.slice(2), { string: ['_'] })
就是這個minimist
包,它的作用就是解析命令行參數(shù),感興趣的可以自行了解,據(jù)說這個包也是百來行代碼。
繼續(xù)往下,這兩個參數(shù)就是我們在執(zhí)行create-vite
命令時傳入的參數(shù),比如:
create-vite my-vite-app
那么argv._[0]
就是my-vite-app
;
如果我們執(zhí)行的是:
create-vite my-vite-app --template vue
那么argv.template
就是vue
。
argv.t
就是argv.template
的簡寫,相當于:
create-vite my-vite-app --t vue # 等價于 create-vite my-vite-app --template vue
通過打斷點的方式,可以看到結果和我們預想的一樣。
formatTargetDir(argv._[0])
就是格式化我們傳入的目錄,它會去掉目錄前后的空格和最后的/
,比如:
formatTargetDir(' my-vite-app ') // my-vite-app formatTargetDir(' my-vite-app/') // my-vite-app
這個代碼很簡單,就不貼出來了,繼續(xù)往下:
let targetDir = argTargetDir || defaultTargetDir
targetDir
是我們最終要創(chuàng)建的目錄,defaultTargetDir
的值是vite-project
,如果我們沒有傳將會用這個值來兜底。
緊接著后面跟著一個getProjectName
的函數(shù),通常來講這種代碼可以跳過先不看,但是這里的getProjectName
函數(shù)有點特殊;
const getProjectName = () => targetDir === '.' ? path.basename(path.resolve()) : targetDir
它會根據(jù)targetDir
的值來判斷我們的項目是不是在當前目錄下創(chuàng)建的,如果是的話,就會返回當前目錄的名字,比如:
create-vite .
可以看到如果項目名稱傳的是.
,那么getProjectName
函數(shù)就會返回當前目錄的名字,也就是create-vite
(根據(jù)自己的情況而定);
不看源碼還真不知道這里還可以這么用,繼續(xù)往下,就是定義了一個問題數(shù)組:
result = await prompts([])
這個prompts
函數(shù)是一個交互式命令行工具,它會根據(jù)我們傳入的問題數(shù)組來進行交互,就比如源碼中,一共列出了6個問題:
projectName
:項目名稱overwrite
:是否覆蓋已存在的目錄overwriteChecker
:檢測覆蓋的目錄是否為空packageName
:包名framework
:框架variant
:語言
當執(zhí)行create-vite
命令時,后面不跟著任何參數(shù),而且我們一切操作都是合規(guī)的,那么只會經(jīng)歷三個問題:
projectName
:項目名稱framework
:框架variant
:語言
projectName:項目名稱
配置項如下:
var projectName = { type: argTargetDir ? null : 'text', name: 'projectName', message: reset('Project name:'), initial: defaultTargetDir, onState: (state) => { targetDir = formatTargetDir(state.value) || defaultTargetDir } }
先來簡單介紹一個每一個配置項的含義:
type
:問題的類型,這里的null
表示不需要用戶輸入,直接跳過這個問題,這個配置項的值可以是text
、select
、confirm
等,具體可以看這里;name
:問題的名稱,這里的projectName
是用來在prompts
函數(shù)的返回值中獲取這個問題的答案的;message
:問題的描述,這里的Project name:
是用來在命令行中顯示的;initial
:問題的默認值,這里的defaultTargetDir
是用來在命令行中顯示的;onState
:問題的回調(diào)函數(shù),每次用戶輸入的時候都會觸發(fā)這個函數(shù),這里的state
就是用戶輸入的值;
可以看到這里的type
配置是根據(jù)argTargetDir
的值來決定的,如果argTargetDir
有值,那么就會跳過這個問題,直接使用argTargetDir
的值作為項目名稱;
如果在使用create-vite
命令時,后面跟著了項目名稱,那么argTargetDir
就有值了,也就是會跳過這個問題,后面的屬性就沒什么好分析了,接著往下。
overwrite:是否覆蓋已存在的目錄
配置項如下:
var overwrite = { type: () => !fs.existsSync(targetDir) || isEmpty(targetDir) ? null : 'confirm', name: 'overwrite', message: () => (targetDir === '.' ? 'Current directory' : `Target directory "${targetDir}"`) + ` is not empty. Remove existing files and continue?` }
這里的type
配置項是一個函數(shù),這個函數(shù)的返回值是null
或者confirm
;
如果targetDir
目錄不存在,或者targetDir
目錄下面沒有東西,那么就會跳過這個問題,直接使用null
作為type
的值;
message
配置項也是一個函數(shù),這個函數(shù)的返回值是一個字符串,這個字符串就是在命令行中顯示的內(nèi)容;
同樣因為人性化的考慮,會顯示不同的提示語來幫助用戶做出選擇;
overwriteChecker:檢測覆蓋的目錄是否為空
配置項如下:
var overwriteChecker = { type: (_, {overwrite}: { overwrite?: boolean }) => { if (overwrite === false) { throw new Error(red('?') + ' Operation cancelled') } return null }, name: 'overwriteChecker' }
overwriteChecker
會在overwrite
問題之后執(zhí)行,這里的type
配置項是一個函數(shù),里面接收了兩個參數(shù);
第一個參數(shù)名為_
,通常這種行為是占位的,表示這個參數(shù)沒有用到,但是又不能省略;
第二個參數(shù)是一個對象,這個對象里面有一個overwrite
屬性,這個屬性就是overwrite
問題的答案;
他通過overwrite
的值來判斷用戶是否選擇了覆蓋,如果選擇了覆蓋,就會跳過這個問題;
否則的話就證明這個目錄下面存在文件,那么就會拋出一個錯誤,這里拋出錯誤是會終止整個命令的執(zhí)行的;
這一部分,在定義問題數(shù)組的時候有做處理,使用try...catch
來捕獲錯誤,如果有錯誤,就會使用return
來終止整個命令的執(zhí)行;
try { result = await prompts([]) } catch (cancelled: any) { console.log(cancelled.message) return }
packageName
:包名
配置項如下:
var packageName = { type: () => (isValidPackageName(getProjectName()) ? null : 'text'), name: 'packageName', message: reset('Package name:'), initial: () => toValidPackageName(getProjectName()), validate: (dir) => isValidPackageName(dir) || 'Invalid package.json name' }
這里的type
配置項是一個函數(shù),里面通過isValidPackageName
來判斷項目名稱是否是一個合法的包名;
getProjectName
在上面已經(jīng)介紹過了,這里就不再贅述;
isValidPackageName
是用來判斷包名是否合法的,這個函數(shù)的實現(xiàn)如下:
function isValidPackageName(projectName: string) { return /^(?:@[a-z\d-*~][a-z\d-*._~]*/)?[a-z\d-~][a-z\d-._~]*$/.test( projectName ) }
validate
用來驗證用戶輸入的內(nèi)容是否合法,如果不合法,就會顯示Invalid package.json name
;
framework:框架
配置項如下:
var framework = { 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 } }) }
這里的就相對來說復雜了點,首先判斷了argTemplate
是否存在,如果存在,就會判斷argTemplate
是否是一個合法的模板;
TEMPLATES
的定義是通過FRAMEWORKS
來生成的:
const TEMPLATES = FRAMEWORKS.map((f) => { const variants = f.variants || []; const names = variants.map((v) => v.name); return names.length ? names : [f.name]; }).reduce((a, b) => a.concat(b), [])
這里我將代碼拆分了一下,這樣看著會更清晰一點,最后的reduce
的作用應該是對值進行一個拷貝處理;
源碼里面的map
返回的都是引用值,所以需要進行拷貝(這是我猜測的),源碼如下:
const TEMPLATES = FRAMEWORKS.map( (f) => (f.variants && f.variants.map((v) => v.name)) || [f.name] ).reduce((a, b) => a.concat(b), [])
FRAMEWORKS
是寫死的一個數(shù)組,代碼很長,就不貼出來了,這里就貼一下type
的定義:
type Framework = { name: string display: string color: ColorFunc variants: FrameworkVariant[] } type FrameworkVariant = { name: string display: string color: ColorFunc customCommand?: string }
name
是框架的名稱;display
是顯示的名稱;color
是顏色;variants
是框架的語言,比如react
有typescript
和javascript
兩種語言;customCommand
是自定義的命令,比如vue
的vue-cli
就是自定義的命令;
分析到這里,再回頭看看framework
的配置項,就很好理解了,這里的choices
就是通過FRAMEWORKS
來生成的:
var framework = { choices: FRAMEWORKS.map((framework) => { const frameworkColor = framework.color return { title: frameworkColor(framework.display || framework.name), value: framework } }) }
choices
是一個數(shù)組,用于表示type
為select
時的選項,數(shù)組的每一項都是一個對象,對象的title
是顯示的名稱,value
是選中的值;
上面的代碼就是用來生成choices
的,frameworkColor
是一個顏色函數(shù),用來給framework.display
或者framework.name
上色;
variant:語言
配置項如下:
var variant = { 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 } }) }
這里的type
是一個函數(shù),函數(shù)的第一個參數(shù)就是framework
,這里的type
是根據(jù)framework
來判斷的,如果framework
存在并且framework.variants
存在,就讓用戶繼續(xù)這一個問題。
通過之前的分析,這一塊應該都能看明白,就繼續(xù)往下走;
獲取用戶輸入
接著往下走就是獲取用戶輸入了,用戶回答完所有問題后,結果會返回到result
中,可以用過解構的方式來獲取:
const { framework, overwrite, packageName, variant } = result
清空目錄
接著就是對生成項目的位置進行處理,根據(jù)上面分析的邏輯,會有目錄下有文件的情況,所以需要先清空目錄:
// 確定項目生成的目錄 const root = path.join(cwd, targetDir) // 清空目錄 if (overwrite) { emptyDir(root) } else if (!fs.existsSync(root)) { fs.mkdirSync(root, {recursive: true}) }
emptyDir
是一個清空目錄的方法,fs.existsSync
是用來判斷目錄是否存在的,如果不存在就創(chuàng)建一個;
function emptyDir(dir: string) { // 如果目錄不存在,啥也不管 if (!fs.existsSync(dir)) { return } // 讀取目錄下的所有文件 for (const file of fs.readdirSync(dir)) { // 忽略 .git 的目錄 if (file === '.git') { continue } // 刪除文件,如果是目錄就遞歸刪除 fs.rmSync(path.resolve(dir, file), { recursive: true, force: true }) } }
existsSync
第二個參數(shù)是一個對象,recursive
表示是否遞歸創(chuàng)建目錄,如果目錄不存在,就會創(chuàng)建目錄,如果目錄存在,就會報錯;
生成項目
繼續(xù)往下走,就是生成項目相關的,最開始肯定是確定項目的內(nèi)容。
確定項目模板
// 確定項目模板 const template: string = variant || framework?.name || argTemplate
這里的template
就是項目的模板,如果用戶選擇了variant
,那么就用variant
,如果沒有選擇,就用framework
,如果framework
不存在,就用argTemplate
;
這些變量代表什么,從哪來的上面都有分析。
確定包管理器
const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent)
這里的process.env.npm_config_user_agent
并不是我們自己定義的,是npm
自己定義的;
這個變量值是指的當前運行環(huán)境的包管理器,比如npm
,yarn
等等,當然這個值肯定沒我寫的這么簡單;
通過debug
可以看到我的值是pnpm/7.17.0 npm/? node/v14.19.2 win32 x64
,每個人的值根據(jù)環(huán)境的不同而不同;
pkgFromUserAgent
是一個解析userAgent
的方法,大白話就是解析包管理器的名稱和版本號;
例如{name: 'npm', version: '7.17.0'}
,代碼如下:
function pkgFromUserAgent(userAgent: string | undefined) { if (!userAgent) return undefined const pkgSpec = userAgent.split(' ')[0] const pkgSpecArr = pkgSpec.split('/') return { name: pkgSpecArr[0], version: pkgSpecArr[1] } }
這個代碼也沒那么高深,就是解析字符串,然后返回一個對象,給你寫也一定可以寫出來的;
后面兩段代碼就是正式確定包管理器的名稱和版本號了,代碼如下:
const pkgManager = pkgInfo ? pkgInfo.name : 'npm' const isYarn1 = pkgManager === 'yarn' && pkgInfo?.version.startsWith('1.')
yarn
的版本如果是1.x
后面會有一些特殊處理,所以會有isYarn1
這個變量;
接著就是確定包管理器的命令了,代碼如下:
const { customCommand } = FRAMEWORKS.flatMap((f) => f.variants).find((v) => v.name === template) ?? {}
這一段是用來確定部分模板的包管理器命令的,比如vue-cli
,vue-cli
的包管理器命令是vue
,會有不一樣的命令;
if (customCommand) { const fullCustomCommand = customCommand .replace('TARGET_DIR', targetDir) .replace(/^npm create/, `${pkgManager} create`) // Only Yarn 1.x doesn't support `@version` in the `create` command .replace('@latest', () => (isYarn1 ? '' : '@latest')) .replace(/^npm exec/, () => { // Prefer `pnpm dlx` or `yarn dlx` if (pkgManager === 'pnpm') { return 'pnpm dlx' } if (pkgManager === 'yarn' && !isYarn1) { return 'yarn dlx' } // Use `npm exec` in all other cases, // including Yarn 1.x and other custom npm clients. return 'npm exec' }) const [command, ...args] = fullCustomCommand.split(' ') const {status} = spawn.sync(command, args, { stdio: 'inherit' }) process.exit(status ?? 0) }
這里的處理代碼比較多,但是也沒什么好看的,就是各種替換字符串,然后生成最終的命令;
正式生成項目
接下來就是重點了,首先確定模板的位置,代碼如下:
const templateDir = path.resolve( fileURLToPath(import.meta.url), '../..', `template-${template}` )
這里的import.meta.url
是當前ES
模塊的絕對路徑,這里是一個知識點。
import
大家都知道是用來導入模塊的,但是import.meta
是什么呢?
import.meta
是一個對象,它的屬性和方法提供了有關模塊的信息,比如url
就是當前模塊的絕對路徑;
同時他還允許在模塊中添加自定義的屬性,比如import.meta.foo = 'bar'
,這樣就可以在模塊中使用import.meta.foo
了;
所以我們在vite
項目中可以使用import.meta.env
來獲取環(huán)境變量,比如import.meta.env.MODE
就是當前的模式;
點到為止,我們繼續(xù)看代碼,這一段就是確定模板的位置,應該都看的懂;
后面就是讀取模板文件,然后生成項目了,代碼如下:
const files = fs.readdirSync(templateDir) // package.json 不需要寫進去 for (const file of files.filter((f) => f !== 'package.json')) { write(file) }
這里的write
函數(shù)就是用來生成項目的,代碼如下:
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) } }
根據(jù)上面的邏輯這個分析直接簡化為:
const write = (file: string) => { const targetPath = path.join(root, file) copy(path.join(templateDir, file), targetPath) }
這個沒啥好說的,然后就到了copy
函數(shù)的分析了,代碼如下:
function copy(src: string, dest: string) { const stat = fs.statSync(src) if (stat.isDirectory()) { copyDir(src, dest) } else { fs.copyFileSync(src, dest) } }
這里的copy
函數(shù)就是用來復制文件的,如果是文件夾就調(diào)用copyDir
函數(shù),代碼如下:
function copyDir(srcDir: string, destDir: string) { fs.mkdirSync(destDir, { recursive: true }) for (const file of fs.readdirSync(srcDir)) { const srcFile = path.resolve(srcDir, file) const destFile = path.resolve(destDir, file) copy(srcFile, destFile) } }
這里的fs.mkdirSync
函數(shù)就是用來創(chuàng)建文件夾的,recursive
參數(shù)表示如果父級文件夾不存在就創(chuàng)建父級文件夾;
這里的fs.readdirSync
函數(shù)就是用來讀取文件夾的,返回一個數(shù)組,數(shù)組中的每一項就是文件夾中的文件名;
最后通過遞歸調(diào)用copy
函數(shù)來復制文件夾中的文件;
創(chuàng)建package.json
接下來是對package.json
文件的單獨處理,代碼如下:
// 獲取模板中的 package.json const pkg = JSON.parse( fs.readFileSync(path.join(templateDir, `package.json`), 'utf-8') ) // 修改 package.json 中的 name 值 pkg.name = packageName || getProjectName() // 寫入 package.json write('package.json', JSON.stringify(pkg, null, 2))
這里的pkg
就是模板中的package.json
文件,然后修改name
字段,最后寫入到項目中;
之前不復制package.json
是因為這里會修改name
字段,如果復制了你的項目的name
屬性就不正確。
完成
console.log(`\nDone. Now run:\n`) if (root !== cwd) { console.log(` cd ${path.relative(cwd, root)}`) } 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()
最后就是一些提示信息,如果你的項目不在當前目錄下,就會提示你cd
到項目目錄下,然后根據(jù)你的包管理器來提示你安裝依賴和啟動項目。
總結
整體下來這個腳手架的實現(xiàn)還是比較簡單的,整體非常清晰:
- 通過
minimist
來解析命令行參數(shù); - 通過
prompts
來交互式的獲取用戶輸入; - 確認用戶輸入的信息,整合項目信息;
- 通過
node
的fs
模塊來創(chuàng)建項目; - 最后提示用戶如何啟動項目。
代碼不多,但是整體走下來還是有很多細節(jié)的,例如:
- 以后寫
node
項目的時候知道怎么獲取命令行參數(shù); - 用戶命令行的交互式輸入,里面用戶體驗是非常好的,這個可以在很多地方是做為參考;
fs
模塊的使用,這個模塊是node
中非常重要的模塊;node
中的path
模塊,這個模塊也是非常重要的,很多地方都會用到;import
的知識點,真的學到了。
以上就是create vite 實例源碼解析的詳細內(nèi)容,更多關于create vite源碼解析的資料請關注腳本之家其它相關文章!
相關文章
vue+element-ui實現(xiàn)表格編輯的三種實現(xiàn)方式
這篇文章主要介紹了vue+element-ui實現(xiàn)表格編輯的三種實現(xiàn)方式,主要有表格內(nèi)部顯示和編輯切換,通過彈出另外一個表格編輯和直接通過樣式控制三種方式,感興趣的小伙伴們可以參考一下2018-10-10Vue實現(xiàn)typeahead組件功能(非常靠譜)
本文給大家分享通過Vue寫一個挺靠譜的typeahead組件功能,非常不錯,具有參考借鑒價值,需要的的朋友參考下吧2017-08-08