Vue 發(fā)布新版腳手架工具,300 行代碼輕盈新生!
1. 前言
美國(guó)時(shí)間 2021 年 10 月 7 日早晨,Vue 團(tuán)隊(duì)等主要貢獻(xiàn)者舉辦了一個(gè) Vue Contributor Days 在線會(huì)議,蔣豪群[1](知乎胖茶[2],Vue.js 官方團(tuán)隊(duì)成員,Vue-CLI 核心開(kāi)發(fā)),在會(huì)上公開(kāi)了create-vue[3],一個(gè)全新的腳手架工具。
create-vue使用npm init vue@next一行命令,就能快如閃電般初始化好基于vite的Vue3項(xiàng)目。
本文就是通過(guò)調(diào)試和大家一起學(xué)習(xí)這個(gè)300余行的源碼。
閱讀本文,你將學(xué)到:
1. 學(xué)會(huì)全新的官方腳手架工具 create-vue 的使用和原理
2. 學(xué)會(huì)使用 VSCode 直接打開(kāi) github 項(xiàng)目
3. 學(xué)會(huì)使用測(cè)試用例調(diào)試源碼
4. 學(xué)以致用,為公司初始化項(xiàng)目寫腳手架工具。
5. 等等
2. 使用 npm init vue@next 初始化 vue3 項(xiàng)目
create-vue github README[4]上寫著,An easy way to start a Vue project。一種簡(jiǎn)單的初始化vue項(xiàng)目的方式。
- npm init vue@next
 
估計(jì)大多數(shù)讀者,第一反應(yīng)是這樣竟然也可以,這么簡(jiǎn)單快捷?
忍不住想動(dòng)手在控制臺(tái)輸出命令,我在終端試過(guò),見(jiàn)下圖。
npm init vue@next
最終cd vue3-project、npm install 、npm run dev打開(kāi)頁(yè)面http://localhost:3000[5]。
初始化頁(yè)面
2.1 npm init && npx
為啥 npm init 也可以直接初始化一個(gè)項(xiàng)目,帶著疑問(wèn),我們翻看 npm 文檔。
npm init[6]
npm init 用法:
- npm init [--force|-f|--yes|-y|--scope]
 - npm init <@scope> (same as `npx <@scope>/create`)
 - npm init [<@scope>/]<name> (same as `npx [<@scope>/]create-<name>`)
 
npm init <initializer> 時(shí)轉(zhuǎn)換成npx命令:
- npm init foo -> npx create-foo
 - npm init @usr/foo -> npx @usr/create-foo
 - npm init @usr -> npx @usr/create
 
看完文檔,我們也就理解了:
- # 運(yùn)行
 - npm init vue@next
 - # 相當(dāng)于
 - npx create-vue@next
 
我們可以在這里create-vue[7],找到一些信息?;蛘咴趎pm create-vue[8]找到版本等信息。
其中@next是指定版本,通過(guò)npm dist-tag ls create-vue命令可以看出,next版本目前對(duì)應(yīng)的是3.0.0-beta.6。
- npm dist-tag ls create-vue
 - - latest: 3.0.0-beta.6
 - - next: 3.0.0-beta.6
 
發(fā)布時(shí) npm publish --tag next 這種寫法指定 tag。默認(rèn)標(biāo)簽是latest。
可能有讀者對(duì) npx 不熟悉,這時(shí)找到阮一峰老師博客 npx 介紹[9]、nodejs.cn npx[10]
npx 是一個(gè)非常強(qiáng)大的命令,從 npm 的 5.2 版本(發(fā)布于 2017 年 7 月)開(kāi)始可用。
簡(jiǎn)單說(shuō)下容易忽略且常用的場(chǎng)景,npx有點(diǎn)類似小程序提出的隨用隨走。
輕松地運(yùn)行本地命令
- node_modules/.bin/vite -v
 - # vite/2.6.5 linux-x64 node-v14.16.0
 - # 等同于
 - # package.json script: "vite -v"
 - # npm run vite
 - npx vite -v
 - # vite/2.6.5 linux-x64 node-v14.16.0
 
使用不同的 Node.js 版本運(yùn)行代碼某些場(chǎng)景下可以臨時(shí)切換 node 版本,有時(shí)比 nvm 包管理方便些。
- npx node@14 -v
 - # v14.18.0
 - npx -p node@14 node -v
 - # v14.18.0
 
無(wú)需安裝的命令執(zhí)行 。
- # 啟動(dòng)本地靜態(tài)服務(wù)
 - npx http-server
 
- # 無(wú)需全局安裝
 - npx @vue/cli create vue-project
 - # @vue/cli 相比 npm init vue@next npx create-vue@next 很慢。
 - # 全局安裝
 - npm i -g @vue/cli
 - vue create vue-project
 
npx vue-cli
npm init vue@next (npx create-vue@next) 快的原因,主要在于依賴少(能不依賴包就不依賴),源碼行數(shù)少,目前index.js只有300余行。
3. 配置環(huán)境調(diào)試源碼
3.1 克隆 create-vue 項(xiàng)目
本文倉(cāng)庫(kù)地址 create-vue-analysis[11],求個(gè)star~
- # 可以直接克隆我的倉(cāng)庫(kù),我的倉(cāng)庫(kù)保留的 create-vue 倉(cāng)庫(kù)的 git 記錄
 - git clone https://github.com/lxchuan12/create-vue-analysis.git
 - cd create-vue-analysis/create-vue
 - npm i
 
當(dāng)然不克隆也可以直接用 VSCode 打開(kāi)我的倉(cāng)庫(kù)。https://open.vscode.dev/lxchuan12/create-vue-analysis
順帶說(shuō)下:我是怎么保留 create-vue 倉(cāng)庫(kù)的 git 記錄的。
- # 在 github 上新建一個(gè)倉(cāng)庫(kù) `create-vue-analysis` 克隆下來(lái)
 - git clone https://github.com/lxchuan12/create-vue-analysis.git
 - cd create-vue-analysis
 - git subtree add --prefix=create-vue https://github.com/vuejs/create-vue.git main
 - # 這樣就把 create-vue 文件夾克隆到自己的 git 倉(cāng)庫(kù)了。且保留的 git 記錄
 
關(guān)于更多 git subtree,可以看Git Subtree 簡(jiǎn)明使用手冊(cè)[12]
3.2 package.json 分析
- // create-vue/package.json
 - {
 - "name": "create-vue",
 - "version": "3.0.0-beta.6",
 - "description": "An easy way to start a Vue project",
 - "type": "module",
 - "bin": {
 - "create-vue": "outfile.cjs"
 - },
 - }
 
bin指定可執(zhí)行腳本。也就是我們可以使用 npx create-vue 的原因。
outfile.cjs 是打包輸出的JS文件
- {
 - "scripts": {
 - "build": "esbuild --bundle index.js --format=cjs --platform=node --outfile=outfile.cjs",
 - "snapshot": "node snapshot.js",
 - "pretest": "run-s build snapshot",
 - "test": "node test.js"
 - },
 - }
 
執(zhí)行 npm run test 時(shí),會(huì)先執(zhí)行鉤子函數(shù) pretest。run-s 是 npm-run-all[13] 提供的命令。run-s build snapshot 命令相當(dāng)于 npm run build && npm run snapshot。
根據(jù)腳本提示,我們來(lái)看 snapshot.js 文件。
3.3 生成快照 snapshot.js
這個(gè)文件主要作用是根據(jù)const featureFlags = ['typescript', 'jsx', 'router', 'vuex', 'with-tests'] 組合生成31種加上 default 共計(jì) 32種 組合,生成快照在 playground目錄。
因?yàn)榇虬傻?outfile.cjs 代碼有做一些處理,不方便調(diào)試,我們可以修改為index.js便于調(diào)試。
- // 路徑 create-vue/snapshot.js
 - const bin = path.resolve(__dirname, './outfile.cjs')
 - // 改成 index.js 便于調(diào)試
 - const bin = path.resolve(__dirname, './index.js')
 
我們可以在for和 createProjectWithFeatureFlags 打上斷點(diǎn)。
createProjectWithFeatureFlags其實(shí)類似在終端輸入如下執(zhí)行這樣的命令
- node ./index.js --xxx --xxx --force
 
- function createProjectWithFeatureFlags(flags) {
 - const projectName = flags.join('-')
 - console.log(`Creating project ${projectName}`)
 - const { status } = spawnSync(
 - 'node',
 - [bin, projectName, ...flags.map((flag) => `--${flag}`), '--force'],
 - {
 - cwd: playgroundDir,
 - stdio: ['pipe', 'pipe', 'inherit']
 - }
 - )
 - if (status !== 0) {
 - process.exit(status)
 - }
 - }
 - // 路徑 create-vue/snapshot.js
 - for (const flags of flagCombinations) {
 - createProjectWithFeatureFlags(flags)
 - }
 
調(diào)試:VSCode打開(kāi)項(xiàng)目,VSCode高版本(1.50+)可以在 create-vue/package.json => scripts => "test": "node test.js"。鼠標(biāo)懸停在test上會(huì)有調(diào)試腳本提示,選擇調(diào)試腳本。如果對(duì)調(diào)試不熟悉,可以看我之前的文章koa-compose,寫的很詳細(xì)。
調(diào)試時(shí),大概率你會(huì)遇到:create-vue/index.js 文件中,__dirname 報(bào)錯(cuò)問(wèn)題??梢园凑杖缦路椒ń鉀Q。在 import 的語(yǔ)句后,添加如下語(yǔ)句,就能愉快的調(diào)試了。
- // 路徑 create-vue/index.js
 - // 解決辦法和nodejs issues
 - // https://stackoverflow.com/questions/64383909/dirname-is-not-defined-in-node-14-version
 - // https://github.com/nodejs/help/issues/2907
 - import { fileURLToPath } from 'url';
 - import { dirname } from 'path';
 - const __filename = fileURLToPath(import.meta.url);
 - const __dirname = dirname(__filename);
 
接著我們調(diào)試 index.js 文件,來(lái)學(xué)習(xí)。
4. 調(diào)試 index.js 主流程
回顧下上文 npm init vue@next 初始化項(xiàng)目的。
npm init vue@next
單從初始化項(xiàng)目輸出圖來(lái)看。主要是三個(gè)步驟。
- 1. 輸入項(xiàng)目名稱,默認(rèn)值是 vue-project
 - 2. 詢問(wèn)一些配置 渲染模板等
 - 3. 完成創(chuàng)建項(xiàng)目,輸出運(yùn)行提示
 
- async function init() {
 - // 省略放在后文詳細(xì)講述
 - }
 - // async 函數(shù)返回的是Promise 可以用 catch 報(bào)錯(cuò)
 - init().catch((e) => {
 - console.error(e)
 - })
 
4.1 解析命令行參數(shù)
- // 返回運(yùn)行當(dāng)前腳本的工作目錄的路徑。
 - const cwd = process.cwd()
 - // possible options:
 - // --default
 - // --typescript / --ts
 - // --jsx
 - // --router / --vue-router
 - // --vuex
 - // --with-tests / --tests / --cypress
 - // --force (for force overwriting)
 - const argv = minimist(process.argv.slice(2), {
 - alias: {
 - typescript: ['ts'],
 - 'with-tests': ['tests', 'cypress'],
 - router: ['vue-router']
 - },
 - // all arguments are treated as booleans
 - boolean: true
 - })
 
minimist[14]
簡(jiǎn)單說(shuō),這個(gè)庫(kù),就是解析命令行參數(shù)的??蠢?,我們比較容易看懂傳參和解析結(jié)果。
- $ node example/parse.js -a beep -b boop
 - { _: [], a: 'beep', b: 'boop' }
 - $ node example/parse.js -x 3 -y 4 -n5 -abc --beep=boop foo bar baz
 - { _: [ 'foo', 'bar', 'baz' ],
 - x: 3,
 - y: 4,
 - n: 5,
 - a: true,
 - b: true,
 - c: true,
 - beep: 'boop' }
 
比如
- npm init vue@next --vuex --force
 
4.2 如果設(shè)置了 feature flags 跳過(guò) prompts 詢問(wèn)
這種寫法方便代碼測(cè)試等。直接跳過(guò)交互式詢問(wèn),同時(shí)也可以省時(shí)間。
- // if any of the feature flags is set, we would skip the feature prompts
 - // use `??` instead of `||` once we drop Node.js 12 support
 - const isFeatureFlagsUsed =
 - typeof (argv.default || argv.ts || argv.jsx || argv.router || argv.vuex || argv.tests) ===
 - 'boolean'
 - // 生成目錄
 - let targetDir = argv._[0]
 - // 默認(rèn) vue-projects
 - const defaultProjectName = !targetDir ? 'vue-project' : targetDir
 - // 強(qiáng)制重寫文件夾,當(dāng)同名文件夾存在時(shí)
 - const forceOverwrite = argv.force
 
4.3 交互式詢問(wèn)一些配置
如上文npm init vue@next 初始化的圖示
- 輸入項(xiàng)目名稱
 - 還有是否刪除已經(jīng)存在的同名目錄
 - 詢問(wèn)使用需要 JSX Router vuex cypress 等。
 
- let result = {}
 - try {
 - // Prompts:
 - // - Project name:
 - // - whether to overwrite the existing directory or not?
 - // - enter a valid package name for package.json
 - // - Project language: JavaScript / TypeScript
 - // - Add JSX Support?
 - // - Install Vue Router for SPA development?
 - // - Install Vuex for state management? (TODO)
 - // - Add Cypress for testing?
 - result = await prompts(
 - [
 - {
 - name: 'projectName',
 - type: targetDir ? null : 'text',
 - message: 'Project name:',
 - initial: defaultProjectName,
 - onState: (state) => (targetDir = String(state.value).trim() || defaultProjectName)
 - },
 - // 省略若干配置
 - {
 - name: 'needsTests',
 - type: () => (isFeatureFlagsUsed ? null : 'toggle'),
 - message: 'Add Cypress for testing?',
 - initial: false,
 - active: 'Yes',
 - inactive: 'No'
 - }
 - ],
 - {
 - onCancel: () => {
 - throw new Error(red('✖') + ' Operation cancelled')
 - }
 - }
 - ]
 - )
 - } catch (cancelled) {
 - console.log(cancelled.message)
 - // 退出當(dāng)前進(jìn)程。
 - process.exit(1)
 - }
 
4.4 初始化詢問(wèn)用戶給到的參數(shù),同時(shí)也會(huì)給到默認(rèn)值
- // `initial` won't take effect if the prompt type is null
 - // so we still have to assign the default values here
 - const {
 - packageName = toValidPackageName(defaultProjectName),
 - shouldOverwrite,
 - needsJsx = argv.jsx,
 - needsTypeScript = argv.typescript,
 - needsRouter = argv.router,
 - needsVuex = argv.vuex,
 - needsTests = argv.tests
 - } = result
 - const root = path.join(cwd, targetDir)
 - // 如果需要強(qiáng)制重寫,清空文件夾
 - if (shouldOverwrite) {
 - emptyDir(root)
 - // 如果不存在文件夾,則創(chuàng)建
 - } else if (!fs.existsSync(root)) {
 - fs.mkdirSync(root)
 - }
 - // 腳手架項(xiàng)目目錄
 - console.log(`\nScaffolding project in ${root}...`)
 - // 生成 package.json 文件
 - const pkg = { name: packageName, version: '0.0.0' }
 - fs.writeFileSync(path.resolve(root, 'package.json'), JSON.stringify(pkg, null, 2))
 
4.5 根據(jù)模板文件生成初始化項(xiàng)目所需文件
- // todo:
 - // work around the esbuild issue that `import.meta.url` cannot be correctly transpiled
 - // when bundling for node and the format is cjs
 - // const templateRoot = new URL('./template', import.meta.url).pathname
 - const templateRoot = path.resolve(__dirname, 'template')
 - const render = function render(templateName) {
 - const templateDir = path.resolve(templateRoot, templateName)
 - renderTemplate(templateDir, root)
 - }
 - // Render base template
 - render('base')
 - // 添加配置
 - // Add configs.
 - if (needsJsx) {
 - render('config/jsx')
 - }
 - if (needsRouter) {
 - render('config/router')
 - }
 - if (needsVuex) {
 - render('config/vuex')
 - }
 - if (needsTests) {
 - render('config/cypress')
 - }
 - if (needsTypeScript) {
 - render('config/typescript')
 - }
 
4.6 渲染生成代碼模板
- // Render code template.
 - // prettier-ignore
 - const codeTemplate =
 - (needsTypeScript ? 'typescript-' : '') +
 - (needsRouter ? 'router' : 'default')
 - render(`code/${codeTemplate}`)
 - // Render entry file (main.js/ts).
 - if (needsVuex && needsRouter) {
 - render('entry/vuex-and-router')
 - } else if (needsVuex) {
 - render('entry/vuex')
 - } else if (needsRouter) {
 - render('entry/router')
 - } else {
 - render('entry/default')
 - }
 
4.7 如果配置了需要 ts
重命名所有的 .js 文件改成 .ts。重命名 jsconfig.json 文件為 tsconfig.json 文件。
jsconfig.json[15] 是VSCode的配置文件,可用于配置跳轉(zhuǎn)等。
把index.html 文件里的 main.js 重命名為 main.ts。
- // Cleanup.
 - if (needsTypeScript) {
 - // rename all `.js` files to `.ts`
 - // rename jsconfig.json to tsconfig.json
 - preOrderDirectoryTraverse(
 - root,
 - () => {},
 - (filepath) => {
 - if (filepath.endsWith('.js')) {
 - fs.renameSync(filepath, filepath.replace(/\.js$/, '.ts'))
 - } else if (path.basename(filepath) === 'jsconfig.json') {
 - fs.renameSync(filepath, filepath.replace(/jsconfig\.json$/, 'tsconfig.json'))
 - }
 - }
 - )
 - // Rename entry in `index.html`
 - const indexHtmlPath = path.resolve(root, 'index.html')
 - const indexHtmlContent = fs.readFileSync(indexHtmlPath, 'utf8')
 - fs.writeFileSync(indexHtmlPath, indexHtmlContent.replace('src/main.js', 'src/main.ts'))
 - }
 
4.8 配置了不需要測(cè)試
因?yàn)樗械哪0宥加袦y(cè)試文件,所以不需要測(cè)試時(shí),執(zhí)行刪除 cypress、/__tests__/ 文件夾
- if (!needsTests) {
 - // All templates assumes the need of tests.
 - // If the user doesn't need it:
 - // rm -rf cypress **/__tests__/
 - preOrderDirectoryTraverse(
 - root,
 - (dirpath) => {
 - const dirname = path.basename(dirpath)
 - if (dirname === 'cypress' || dirname === '__tests__') {
 - emptyDir(dirpath)
 - fs.rmdirSync(dirpath)
 - }
 - },
 - () => {}
 - )
 - }
 
4.9 根據(jù)使用的 npm / yarn / pnpm 生成README.md 文件,給出運(yùn)行項(xiàng)目的提示
- // Instructions:
 - // Supported package managers: pnpm > yarn > npm
 - // Note: until <https://github.com/pnpm/pnpm/issues/3505> is resolved,
 - // it is not possible to tell if the command is called by `pnpm init`.
 - const packageManager = /pnpm/.test(process.env.npm_execpath)
 - ? 'pnpm'
 - : /yarn/.test(process.env.npm_execpath)
 - ? 'yarn'
 - : 'npm'
 - // README generation
 - fs.writeFileSync(
 - path.resolve(root, 'README.md'),
 - generateReadme({
 - projectName: result.projectName || defaultProjectName,
 - packageManager,
 - needsTypeScript,
 - needsTests
 - })
 - )
 - console.log(`\nDone. Now run:\n`)
 - if (root !== cwd) {
 - console.log(` ${bold(green(`cd ${path.relative(cwd, root)}`))}`)
 - }
 - console.log(` ${bold(green(getCommand(packageManager, 'install')))}`)
 - console.log(` ${bold(green(getCommand(packageManager, 'dev')))}`)
 - console.log()
 
5. npm run test => node test.js 測(cè)試
- // create-vue/test.js
 - import fs from 'fs'
 - import path from 'path'
 - import { fileURLToPath } from 'url'
 - import { spawnSync } from 'child_process'
 - const __dirname = path.dirname(fileURLToPath(import.meta.url))
 - const playgroundDir = path.resolve(__dirname, './playground/')
 - for (const projectName of fs.readdirSync(playgroundDir)) {
 - if (projectName.endsWith('with-tests')) {
 - console.log(`Running unit tests in ${projectName}`)
 - const unitTestResult = spawnSync('pnpm', ['test:unit:ci'], {
 - cwd: path.resolve(playgroundDir, projectName),
 - stdio: 'inherit',
 - shell: true
 - })
 - if (unitTestResult.status !== 0) {
 - throw new Error(`Unit tests failed in ${projectName}`)
 - }
 - console.log(`Running e2e tests in ${projectName}`)
 - const e2eTestResult = spawnSync('pnpm', ['test:e2e:ci'], {
 - cwd: path.resolve(playgroundDir, projectName),
 - stdio: 'inherit',
 - shell: true
 - })
 - if (e2eTestResult.status !== 0) {
 - throw new Error(`E2E tests failed in ${projectName}`)
 - }
 - }
 - }
 
主要對(duì)生成快照時(shí)生成的在 playground 32個(gè)文件夾,進(jìn)行如下測(cè)試。
- pnpm test:unit:ci
 - pnpm test:e2e:ci
 
6. 總結(jié)
我們使用了快如閃電般的npm init vue@next,學(xué)習(xí)npx命令了。學(xué)會(huì)了其原理。
- npm init vue@next => npx create-vue@next
 
快如閃電的原因在于依賴的很少。很多都是自己來(lái)實(shí)現(xiàn)。如:Vue-CLI中 vue create vue-project 命令是用官方的npm包validate-npm-package-name[16],刪除文件夾一般都是使用 rimraf[17]。而 create-vue 是自己實(shí)現(xiàn)emptyDir和isValidPackageName。
非常建議讀者朋友按照文中方法使用VSCode調(diào)試 create-vue 源碼。源碼中還有很多細(xì)節(jié)文中由于篇幅有限,未全面展開(kāi)講述。



















 
 
 









 
 
 
 