- 前言
- 2. 使用 npm init vue@next 初始化 vue3 项目
- 3. 配置环境调试源码
- 4. 调试 index.js 主流程
- 4.1 解析命令行参数
- 4.2 如果设置了 feature flags 跳过 prompts 询问
- 4.3 交互式询问一些配置
- 4.4 初始化询问用户给到的参数,同时也会给到默认值
- 4.5 根据模板文件生成初始化项目所需文件
- 4.6 渲染生成代码模板
- 4.7 如果配置了需要 ts
- 4.8 配置了不需要测试
- 4.9 根据使用的 npm / yarn / pnpm 生成 README.md 文件,给出运行项目的提示
- 5. npm run test => node test.js 测试
- 6. 总结
前言
美国时间 2021 年 10 月 7 日早晨,Vue 团队等主要贡献者举办了一个 Vue Contributor Days
在线会议,蒋豪群(知乎胖茶,Vue.js 官方团队成员,Vue-CLI 核心开发),在会上公开了create-vue
,一个全新的脚手架工具。
create-vue
使用npm init vue@next
一行命令,就能快如闪电般
初始化好基于vite
的Vue3
项目。
本文就是通过调试和大家一起学习这个 300 余行的源码。
阅读本文,你将学到:
1. 学会全新的官方脚手架工具 create-vue 的使用和原理
2. 学会使用 VSCode 直接打开 github 项目
3. 学会使用测试用例调试源码
4. 学以致用,为公司初始化项目写脚手架工具。
5. 等等
2. 使用 npm init vue@next 初始化 vue3 项目
create-vue github README上写着,An easy way to start a Vue project
。一种简单的初始化 vue 项目的方式。
npm init vue@next
估计大多数读者,第一反应是这样竟然也可以,这么简单快捷?
忍不住想动手在控制台输出命令,我在终端试过,见下图。
单从初始化项目输出图来看。主要是三个步骤。
- 输入项目名称,默认值是 vue-project;
- 询问一些配置 渲染模板等;
- 完成创建项目,输出运行提示。
async function init() { // 省略放在后文详细讲述 } // async 函数返回的是 Promise 可以用 catch 报错 init().catch((e) => { console.error(e) })
4.1 解析命令行参数
// 返回运行当前脚本的工作目录的路径。 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 })
简单说,这个库,就是解析命令行参数的。看例子,我们比较容易看懂传参和解析结果。
$ 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 如果设置了 feature flags 跳过 prompts 询问
这种写法方便代码测试等。直接跳过交互式询问,同时也可以省时间。
// 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] // 默认 vue-projects const defaultProjectName = !targetDir ? 'vue-project' : targetDir // 强制重写文件夹,当同名文件夹存在时 const forceOverwrite = argv.force
4.3 交互式询问一些配置
如上文npm init vue@next
初始化的图示
- 输入项目名称
- 还有是否删除已经存在的同名目录
- 询问使用需要 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) // 退出当前进程。 process.exit(1) }
4.4 初始化询问用户给到的参数,同时也会给到默认值
// `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) // 如果需要强制重写,清空文件夹 if (shouldOverwrite) { emptyDir(root) // 如果不存在文件夹,则创建 } else if (!fs.existsSync(root)) { fs.mkdirSync(root) } // 脚手架项目目录 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 根据模板文件生成初始化项目所需文件
// 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 是 VSCode 的配置文件,可用于配置跳转等。
把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 配置了不需要测试
因为所有的模板都有测试文件,所以不需要测试时,执行删除 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 根据使用的 npm / yarn / pnpm 生成 README.md 文件,给出运行项目的提示
// 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 测试
// 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}`) } } }
主要对生成快照时生成的在 playground
32 个文件夹,进行如下测试。
pnpm test:unit:ci pnpm test:e2e:ci
6. 总结
我们使用了快如闪电般的npm init vue@next
,学习npx
命令了。学会了其原理。
npm init vue@next => npx create-vue@next
之所以快如闪电,就在于依赖的很少,很多都是自己来实现。如:Vue-CLI
中 vue create vue-project
命令是用官方的npm
包validate-npm-package-name,删除文件夹一般都是使用 rimraf。而 create-vue
是自己实现emptyDir
和isValidPackageName
。
非常建议读者朋友按照文中方法使用VSCode
调试 create-vue
源码。源码中还有很多细节文中由于篇幅有限,未全面展开讲述。
学完本文,可以为自己或者公司创建类似初始化脚手架。
目前版本是3.0.0-beta.6
。我们持续关注学习它。除了 create-vue 之外,我们还可以看看create-vite、create-umi 的源码实现。