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项目的方式。
- npminitvue@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 用法:
- npminit[--force|-f|--yes|-y|--scope]
- npminit<@scope>(sameas`npx<@scope>/create`)
- npminit[<@scope>/]<name>(sameas`npx[<@scope>/]create-<name>`)
npm init
- npm init foo -> npx create-foo
- npm init @usr/foo -> npx @usr/create-foo
- npm init @usr -> npx @usr/create
看完文档,我们也就理解了:
- #运行
- npminitvue@next
- #相当于
- npxcreate-vue@next
我们可以在这里create-vue[7],找到一些信息。或者在npm create-vue[8]找到版本等信息。
其中@next是指定版本,通过npm dist-tag ls create-vue命令可以看出,next版本目前对应的是3.0.0-beta.6。
- npmdist-taglscreate-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.5linux-x64node-v14.16.0
- #等同于
- #package.jsonscript:"vite-v"
- #npmrunvite
- npxvite-v
- #vite/2.6.5linux-x64node-v14.16.0
使用不同的 Node.js 版本运行代码某些场景下可以临时切换 node 版本,有时比 nvm 包管理方便些。
- npxnode@14-v
- #v14.18.0
- npx-pnode@14node-v
- #v14.18.0
无需安装的命令执行 。
- #启动本地静态服务
- npxhttp-server
- #无需全局安装
- npx@vue/clicreatevue-project
- #@vue/cli相比npminitvue@nextnpxcreate-vue@next很慢。
- #全局安装
- npmi-g@vue/cli
- vuecreatevue-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记录
- gitclonehttps://github.com/lxchuan12/create-vue-analysis.git
- cdcreate-vue-analysis/create-vue
- npmi
当然不克隆也可以直接用 VSCode 打开我的仓库。https://open.vscode.dev/lxchuan12/create-vue-analysis
顺带说下:我是怎么保留 create-vue 仓库的 git 记录的。
- #在github上新建一个仓库`create-vue-analysis`克隆下来
- gitclonehttps://github.com/lxchuan12/create-vue-analysis.git
- cdcreate-vue-analysis
- gitsubtreeadd--prefix=create-vuehttps://github.com/vuejs/create-vue.gitmain
- #这样就把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":"AneasywaytostartaVueproject",
- "type":"module",
- "bin":{
- "create-vue":"outfile.cjs"
- },
- }
bin指定可执行脚本。也就是我们可以使用 npx create-vue 的原因。
outfile.cjs 是打包输出的JS文件
- {
- "scripts":{
- "build":"esbuild--bundleindex.js--format=cjs--platform=node--outfile=outfile.cjs",
- "snapshot":"nodesnapshot.js",
- "pretest":"run-sbuildsnapshot",
- "test":"nodetest.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
- constbin=path.resolve(__dirname,'./outfile.cjs')
- //改成index.js便于调试
- constbin=path.resolve(__dirname,'./index.js')
我们可以在for和 createProjectWithFeatureFlags 打上断点。
createProjectWithFeatureFlags其实类似在终端输入如下执行这样的命令
- node./index.js--xxx--xxx--force
- functioncreateProjectWithFeatureFlags(flags){
- constprojectName=flags.join('-')
- console.log(`Creatingproject${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(constflagsofflagCombinations){
- 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
- //解决办法和nodejsissues
- //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.完成创建项目,输出运行提示
- asyncfunctioninit(){
- //省略放在后文详细讲述
- }
- //async函数返回的是Promise可以用catch报错
- init().catch((e)=>{
- console.error(e)
- })
4.1 解析命令行参数
- //返回运行当前脚本的工作目录的路径。
- constcwd=process.cwd()
- //possibleoptions:
- //--default
- //--typescript/--ts
- //--jsx
- //--router/--vue-router
- //--vuex
- //--with-tests/--tests/--cypress
- //--force(forforceoverwriting)
- constargv=minimist(process.argv.slice(2),{
- alias:{
- typescript:['ts'],
- 'with-tests':['tests','cypress'],
- router:['vue-router']
- },
- //allargumentsaretreatedasbooleans
- boolean:true
- })
minimist[14]
简单说,这个库,就是解析命令行参数的。看例子,我们比较容易看懂传参和解析结果。
- $nodeexample/parse.js-abeep-bboop
- {_:[],a:'beep',b:'boop'}
- $nodeexample/parse.js-x3-y4-n5-abc--beep=boopfoobarbaz
- {_:['foo','bar','baz'],
- x:3,
- y:4,
- n:5,
- a:true,
- b:true,
- c:true,
- beep:'boop'}
比如
- npminitvue@next--vuex--force
4.2 如果设置了 feature flags 跳过 prompts 询问
这种写法方便代码测试等。直接跳过交互式询问,同时也可以省时间。
- //ifanyofthefeatureflagsisset,wewouldskipthefeatureprompts
- //use`??`insteadof`||`oncewedropNode.js12support
- constisFeatureFlagsUsed=
- typeof(argv.default||argv.ts||argv.jsx||argv.router||argv.vuex||argv.tests)===
- 'boolean'
- //生成目录
- lettargetDir=argv._[0]
- //默认vue-projects
- constdefaultProjectName=!targetDir?'vue-project':targetDir
- //强制重写文件夹,当同名文件夹存在时
- constforceOverwrite=argv.force
4.3 交互式询问一些配置
如上文npm init vue@next 初始化的图示
- 输入项目名称
- 还有是否删除已经存在的同名目录
- 询问使用需要 JSX Router vuex cypress 等。
- letresult={}
- try{
- //Prompts:
- //-Projectname:
- //-whethertooverwritetheexistingdirectoryornot?
- //-enteravalidpackagenameforpackage.json
- //-Projectlanguage:JavaScript/TypeScript
- //-AddJSXSupport?
- //-InstallVueRouterforSPAdevelopment?
- //-InstallVuexforstatemanagement?(TODO)
- //-AddCypressfortesting?
- result=awaitprompts(
- [
- {
- name:'projectName',
- type:targetDir?null:'text',
- message:'Projectname:',
- initial:defaultProjectName,
- onState:(state)=>(targetDir=String(state.value).trim()||defaultProjectName)
- },
- //省略若干配置
- {
- name:'needsTests',
- type:()=>(isFeatureFlagsUsed?null:'toggle'),
- message:'AddCypressfortesting?',
- initial:false,
- active:'Yes',
- inactive:'No'
- }
- ],
- {
- onCancel:()=>{
- thrownewError(red('✖')+'Operationcancelled')
- }
- }
- ]
- )
- }catch(cancelled){
- console.log(cancelled.message)
- //退出当前进程。
- process.exit(1)
- }
4.4 初始化询问用户给到的参数,同时也会给到默认值
- //`initial`won'ttakeeffectiftheprompttypeisnull
- //sowestillhavetoassignthedefaultvalueshere
- const{
- packageName=toValidPackageName(defaultProjectName),
- shouldOverwrite,
- needsJsx=argv.jsx,
- needsTypeScript=argv.typescript,
- needsRouter=argv.router,
- needsVuex=argv.vuex,
- needsTests=argv.tests
- }=result
- constroot=path.join(cwd,targetDir)
- //如果需要强制重写,清空文件夹
- if(shouldOverwrite){
- emptyDir(root)
- //如果不存在文件夹,则创建
- }elseif(!fs.existsSync(root)){
- fs.mkdirSync(root)
- }
- //脚手架项目目录
- console.log(`\nScaffoldingprojectin${root}...`)
- //生成package.json文件
- constpkg={name:packageName,version:'0.0.0'}
- fs.writeFileSync(path.resolve(root,'package.json'),JSON.stringify(pkg,null,2))
4.5 根据模板文件生成初始化项目所需文件
- //todo:
- //workaroundtheesbuildissuethat`import.meta.url`cannotbecorrectlytranspiled
- //whenbundlingfornodeandtheformatiscjs
- //consttemplateRoot=newURL('./template',import.meta.url).pathname
- consttemplateRoot=path.resolve(__dirname,'template')
- constrender=functionrender(templateName){
- consttemplateDir=path.resolve(templateRoot,templateName)
- renderTemplate(templateDir,root)
- }
- //Renderbasetemplate
- render('base')
- //添加配置
- //Addconfigs.
- 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 渲染生成代码模板
- //Rendercodetemplate.
- //prettier-ignore
- constcodeTemplate=
- (needsTypeScript?'typescript-':'')+
- (needsRouter?'router':'default')
- render(`code/${codeTemplate}`)
- //Renderentryfile(main.js/ts).
- if(needsVuex&&needsRouter){
- render('entry/vuex-and-router')
- }elseif(needsVuex){
- render('entry/vuex')
- }elseif(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){
- //renameall`.js`filesto`.ts`
- //renamejsconfig.jsontotsconfig.json
- preOrderDirectoryTraverse(
- root,
- ()=>{},
- (filepath)=>{
- if(filepath.endsWith('.js')){
- fs.renameSync(filepath,filepath.replace(/\.js$/,'.ts'))
- }elseif(path.basename(filepath)==='jsconfig.json'){
- fs.renameSync(filepath,filepath.replace(/jsconfig\.json$/,'tsconfig.json'))
- }
- }
- )
- //Renameentryin`index.html`
- constindexHtmlPath=path.resolve(root,'index.html')
- constindexHtmlContent=fs.readFileSync(indexHtmlPath,'utf8')
- fs.writeFileSync(indexHtmlPath,indexHtmlContent.replace('src/main.js','src/main.ts'))
- }
4.8 配置了不需要测试
因为所有的模板都有测试文件,所以不需要测试时,执行删除 cypress、/__tests__/ 文件夹
- if(!needsTests){
- //Alltemplatesassumestheneedoftests.
- //Iftheuserdoesn'tneedit:
- //rm-rfcypress**/__tests__/
- preOrderDirectoryTraverse(
- root,
- (dirpath)=>{
- constdirname=path.basename(dirpath)
- if(dirname==='cypress'||dirname==='__tests__'){
- emptyDir(dirpath)
- fs.rmdirSync(dirpath)
- }
- },
- ()=>{}
- )
- }
4.9 根据使用的 npm / yarn / pnpm 生成README.md 文件,给出运行项目的提示
- //Instructions:
- //Supportedpackagemanagers:pnpm>yarn>npm
- //Note:until<https://github.com/pnpm/pnpm/issues/3505>isresolved,
- //itisnotpossibletotellifthecommandiscalledby`pnpminit`.
- constpackageManager=/pnpm/.test(process.env.npm_execpath)
- ?'pnpm'
- :/yarn/.test(process.env.npm_execpath)
- ?'yarn'
- :'npm'
- //READMEgeneration
- fs.writeFileSync(
- path.resolve(root,'README.md'),
- generateReadme({
- projectName:result.projectName||defaultProjectName,
- packageManager,
- needsTypeScript,
- needsTests
- })
- )
- console.log(`\nDone.Nowrun:\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
- importfsfrom'fs'
- importpathfrom'path'
- import{fileURLToPath}from'url'
- import{spawnSync}from'child_process'
- const__dirname=path.dirname(fileURLToPath(import.meta.url))
- constplaygroundDir=path.resolve(__dirname,'./playground/')
- for(constprojectNameoffs.readdirSync(playgroundDir)){
- if(projectName.endsWith('with-tests')){
- console.log(`Runningunittestsin${projectName}`)
- constunitTestResult=spawnSync('pnpm',['test:unit:ci'],{
- cwd:path.resolve(playgroundDir,projectName),
- stdio:'inherit',
- shell:true
- })
- if(unitTestResult.status!==0){
- thrownewError(`Unittestsfailedin${projectName}`)
- }
- console.log(`Runninge2etestsin${projectName}`)
- conste2eTestResult=spawnSync('pnpm',['test:e2e:ci'],{
- cwd:path.resolve(playgroundDir,projectName),
- stdio:'inherit',
- shell:true
- })
- if(e2eTestResult.status!==0){
- thrownewError(`E2Etestsfailedin${projectName}`)
- }
- }
- }
主要对生成快照时生成的在 playground 32个文件夹,进行如下测试。
- pnpmtest:unit:ci
- pnpmtest:e2e:ci
6. 总结
我们使用了快如闪电般的npm init vue@next,学习npx命令了。学会了其原理。
- npminitvue@next=>npxcreate-vue@next
快如闪电的原因在于依赖的很少。很多都是自己来实现。如:Vue-CLI中 vue create vue-project 命令是用官方的npm包validate-npm-package-name[16],删除文件夹一般都是使用 rimraf[17]。而 create-vue 是自己实现emptyDir和isValidPackageName。
非常建议读者朋友按照文中方法使用VSCode调试 create-vue 源码。源码中还有很多细节文中由于篇幅有限,未全面展开讲述。
原文地址:https://mp.weixin.qq.com/s/M36SaFesOLfEs-Supe0XdA