1. 前言
美国时间 2021 年 10 月 7 日早晨,Vue 团队等主要贡献者举办了一个 Vue Contributor Days 在线会议,蒋豪群[1](知乎胖茶[2],Vue.js 官方团队成员,Vue-CLI 核心开发),在会上公开了create-vue[3],一个全新的脚手架工具。
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[4]上写着,An easy way to start a Vue project。一种简单的初始化vue项目的方式。
- npm init vue@next
估计大多数读者,第一反应是这样竟然也可以,这么简单快捷?
忍不住想动手在控制台输出命令,我在终端试过,见下图。
npm init vue@next
最终cd vue3-project、npm install 、npm run dev打开页面http://localhost:3000[5]。
初始化页面
2.1 npm init && npx
为啥 npm init 也可以直接初始化一个项目,带着疑问,我们翻看 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> 时转换成npx命令:
- npm init foo -> npx create-foo
- npm init @usr/foo -> npx @usr/create-foo
- npm init @usr -> npx @usr/create
看完文档,我们也就理解了:
- # 运行
- npm init vue@next
- # 相当于
- npx create-vue@next
我们可以在这里create-vue[7],找到一些信息。或者在npm create-vue[8]找到版本等信息。
其中@next是指定版本,通过npm dist-tag ls create-vue命令可以看出,next版本目前对应的是3.0.0-beta.6。
- npm dist-tag ls create-vue
- - latest: 3.0.0-beta.6
- - next: 3.0.0-beta.6
发布时 npm publish --tag next 这种写法指定 tag。默认标签是latest。
可能有读者对 npx 不熟悉,这时找到阮一峰老师博客 npx 介绍[9]、nodejs.cn npx[10]
npx 是一个非常强大的命令,从 npm 的 5.2 版本(发布于 2017 年 7 月)开始可用。
简单说下容易忽略且常用的场景,npx有点类似小程序提出的随用随走。
轻松地运行本地命令
- 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 版本运行代码某些场景下可以临时切换 node 版本,有时比 nvm 包管理方便些。
- npx node@14 -v
- # v14.18.0
- npx -p node@14 node -v
- # v14.18.0
无需安装的命令执行 。
- # 启动本地静态服务
- npx http-server
- # 无需全局安装
- 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) 快的原因,主要在于依赖少(能不依赖包就不依赖),源码行数少,目前index.js只有300余行。
3. 配置环境调试源码
3.1 克隆 create-vue 项目
本文仓库地址 create-vue-analysis[11],求个star~
- # 可以直接克隆我的仓库,我的仓库保留的 create-vue 仓库的 git 记录
- git clone https://github.com/lxchuan12/create-vue-analysis.git
- cd create-vue-analysis/create-vue
- npm i
当然不克隆也可以直接用 VSCode 打开我的仓库。https://open.vscode.dev/lxchuan12/create-vue-analysis
顺带说下:我是怎么保留 create-vue 仓库的 git 记录的。
- # 在 github 上新建一个仓库 `create-vue-analysis` 克隆下来
- 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 仓库了。且保留的 git 记录
关于更多 git subtree,可以看Git Subtree 简明使用手册[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指定可执行脚本。也就是我们可以使用 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"
- },
- }
执行 npm run test 时,会先执行钩子函数 pretest。run-s 是 npm-run-all[13] 提供的命令。run-s build snapshot 命令相当于 npm run build && npm run snapshot。
根据脚本提示,我们来看 snapshot.js 文件。
3.3 生成快照 snapshot.js
这个文件主要作用是根据const featureFlags = ['typescript', 'jsx', 'router', 'vuex', 'with-tests'] 组合生成31种加上 default 共计 32种 组合,生成快照在 playground目录。
因为打包生成的 outfile.cjs 代码有做一些处理,不方便调试,我们可以修改为index.js便于调试。
- // 路径 create-vue/snapshot.js
- const bin = path.resolve(__dirname, './outfile.cjs')
- // 改成 index.js 便于调试
- const bin = path.resolve(__dirname, './index.js')
我们可以在for和 createProjectWithFeatureFlags 打上断点。
createProjectWithFeatureFlags其实类似在终端输入如下执行这样的命令
- 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)
- }
调试:VSCode打开项目,VSCode高版本(1.50+)可以在 create-vue/package.json => scripts => "test": "node test.js"。鼠标悬停在test上会有调试脚本提示,选择调试脚本。如果对调试不熟悉,可以看我之前的文章koa-compose,写的很详细。
调试时,大概率你会遇到:create-vue/index.js 文件中,__dirname 报错问题。可以按照如下方法解决。在 import 的语句后,添加如下语句,就能愉快的调试了。
- // 路径 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);
接着我们调试 index.js 文件,来学习。
4. 调试 index.js 主流程
回顾下上文 npm init vue@next 初始化项目的。
npm init vue@next
单从初始化项目输出图来看。主要是三个步骤。
- 1. 输入项目名称,默认值是 vue-project
- 2. 询问一些配置 渲染模板等
- 3. 完成创建项目,输出运行提示
- 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
- })
minimist[14]
简单说,这个库,就是解析命令行参数的。看例子,我们比较容易看懂传参和解析结果。
- $ 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[15] 是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[16],删除文件夹一般都是使用 rimraf[17]。而 create-vue 是自己实现emptyDir和isValidPackageName。
非常建议读者朋友按照文中方法使用VSCode调试 create-vue 源码。源码中还有很多细节文中由于篇幅有限,未全面展开讲述。