Vue 发布新版脚手架工具,300 行代码轻盈新生!

时间:2021-07-29 02:46:28

Vue 发布新版脚手架工具,300 行代码轻盈新生!

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项目的方式。

  1. npminitvue@next

估计大多数读者,第一反应是这样竟然也可以,这么简单快捷?

忍不住想动手在控制台输出命令,我在终端试过,见下图。

Vue 发布新版脚手架工具,300 行代码轻盈新生!

npm init vue@next

最终cd vue3-project、npm install 、npm run dev打开页面http://localhost:3000[5]。

Vue 发布新版脚手架工具,300 行代码轻盈新生!

初始化页面

2.1 npm init && npx

为啥 npm init 也可以直接初始化一个项目,带着疑问,我们翻看 npm 文档。

npm init[6]

npm init 用法:

  1. npminit[--force|-f|--yes|-y|--scope]
  2. npminit<@scope>(sameas`npx<@scope>/create`)
  3. npminit[<@scope>/]<name>(sameas`npx[<@scope>/]create-<name>`)

npm init 时转换成npx命令:

  • npm init foo -> npx create-foo
  • npm init @usr/foo -> npx @usr/create-foo
  • npm init @usr -> npx @usr/create

看完文档,我们也就理解了:

  1. #运行
  2. npminitvue@next
  3. #相当于
  4. npxcreate-vue@next

我们可以在这里create-vue[7],找到一些信息。或者在npm create-vue[8]找到版本等信息。

其中@next是指定版本,通过npm dist-tag ls create-vue命令可以看出,next版本目前对应的是3.0.0-beta.6。

  1. npmdist-taglscreate-vue
  2. -latest:3.0.0-beta.6
  3. -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有点类似小程序提出的随用随走。

轻松地运行本地命令

  1. node_modules/.bin/vite-v
  2. #vite/2.6.5linux-x64node-v14.16.0
  3. #等同于
  4. #package.jsonscript:"vite-v"
  5. #npmrunvite
  6. npxvite-v
  7. #vite/2.6.5linux-x64node-v14.16.0

使用不同的 Node.js 版本运行代码某些场景下可以临时切换 node 版本,有时比 nvm 包管理方便些。

  1. npxnode@14-v
  2. #v14.18.0
  3. npx-pnode@14node-v
  4. #v14.18.0

无需安装的命令执行 。

  1. #启动本地静态服务
  2. npxhttp-server
  1. #无需全局安装
  2. npx@vue/clicreatevue-project
  3. #@vue/cli相比npminitvue@nextnpxcreate-vue@next很慢。
  4. #全局安装
  5. npmi-g@vue/cli
  6. vuecreatevue-project

Vue 发布新版脚手架工具,300 行代码轻盈新生!

npx vue-cli

npm init vue@next (npx create-vue@next) 快的原因,主要在于依赖少(能不依赖包就不依赖),源码行数少,目前index.js只有300余行。

3. 配置环境调试源码

3.1 克隆 create-vue 项目

本文仓库地址 create-vue-analysis[11],求个star~

  1. #可以直接克隆我的仓库,我的仓库保留的create-vue仓库的git记录
  2. gitclonehttps://github.com/lxchuan12/create-vue-analysis.git
  3. cdcreate-vue-analysis/create-vue
  4. npmi

当然不克隆也可以直接用 VSCode 打开我的仓库。https://open.vscode.dev/lxchuan12/create-vue-analysis

顺带说下:我是怎么保留 create-vue 仓库的 git 记录的。

  1. #在github上新建一个仓库`create-vue-analysis`克隆下来
  2. gitclonehttps://github.com/lxchuan12/create-vue-analysis.git
  3. cdcreate-vue-analysis
  4. gitsubtreeadd--prefix=create-vuehttps://github.com/vuejs/create-vue.gitmain
  5. #这样就把create-vue文件夹克隆到自己的git仓库了。且保留的git记录

关于更多 git subtree,可以看Git Subtree 简明使用手册[12]

3.2 package.json 分析

  1. //create-vue/package.json
  2. {
  3. "name":"create-vue",
  4. "version":"3.0.0-beta.6",
  5. "description":"AneasywaytostartaVueproject",
  6. "type":"module",
  7. "bin":{
  8. "create-vue":"outfile.cjs"
  9. },
  10. }

bin指定可执行脚本。也就是我们可以使用 npx create-vue 的原因。

outfile.cjs 是打包输出的JS文件

  1. {
  2. "scripts":{
  3. "build":"esbuild--bundleindex.js--format=cjs--platform=node--outfile=outfile.cjs",
  4. "snapshot":"nodesnapshot.js",
  5. "pretest":"run-sbuildsnapshot",
  6. "test":"nodetest.js"
  7. },
  8. }

执行 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便于调试。

  1. //路径create-vue/snapshot.js
  2. constbin=path.resolve(__dirname,'./outfile.cjs')
  3. //改成index.js便于调试
  4. constbin=path.resolve(__dirname,'./index.js')

我们可以在for和 createProjectWithFeatureFlags 打上断点。

createProjectWithFeatureFlags其实类似在终端输入如下执行这样的命令

  1. node./index.js--xxx--xxx--force
  1. functioncreateProjectWithFeatureFlags(flags){
  2. constprojectName=flags.join('-')
  3. console.log(`Creatingproject${projectName}`)
  4. const{status}=spawnSync(
  5. 'node',
  6. [bin,projectName,...flags.map((flag)=>`--${flag}`),'--force'],
  7. {
  8. cwd:playgroundDir,
  9. stdio:['pipe','pipe','inherit']
  10. }
  11. )
  12. if(status!==0){
  13. process.exit(status)
  14. }
  15. }
  16. //路径create-vue/snapshot.js
  17. for(constflagsofflagCombinations){
  18. createProjectWithFeatureFlags(flags)
  19. }

调试:VSCode打开项目,VSCode高版本(1.50+)可以在 create-vue/package.json => scripts => "test": "node test.js"。鼠标悬停在test上会有调试脚本提示,选择调试脚本。如果对调试不熟悉,可以看我之前的文章koa-compose,写的很详细。

调试时,大概率你会遇到:create-vue/index.js 文件中,__dirname 报错问题。可以按照如下方法解决。在 import 的语句后,添加如下语句,就能愉快的调试了。

  1. //路径create-vue/index.js
  2. //解决办法和nodejsissues
  3. //https://*.com/questions/64383909/dirname-is-not-defined-in-node-14-version
  4. //https://github.com/nodejs/help/issues/2907
  5. import{fileURLToPath}from'url';
  6. import{dirname}from'path';
  7. const__filename=fileURLToPath(import.meta.url);
  8. const__dirname=dirname(__filename);

接着我们调试 index.js 文件,来学习。

4. 调试 index.js 主流程

回顾下上文 npm init vue@next 初始化项目的。

Vue 发布新版脚手架工具,300 行代码轻盈新生!

npm init vue@next

单从初始化项目输出图来看。主要是三个步骤。

  1. 1.输入项目名称,默认值是vue-project
  2. 2.询问一些配置渲染模板等
  3. 3.完成创建项目,输出运行提示
  1. asyncfunctioninit(){
  2. //省略放在后文详细讲述
  3. }
  4. //async函数返回的是Promise可以用catch报错
  5. init().catch((e)=>{
  6. console.error(e)
  7. })

4.1 解析命令行参数

  1. //返回运行当前脚本的工作目录的路径。
  2. constcwd=process.cwd()
  3. //possibleoptions:
  4. //--default
  5. //--typescript/--ts
  6. //--jsx
  7. //--router/--vue-router
  8. //--vuex
  9. //--with-tests/--tests/--cypress
  10. //--force(forforceoverwriting)
  11. constargv=minimist(process.argv.slice(2),{
  12. alias:{
  13. typescript:['ts'],
  14. 'with-tests':['tests','cypress'],
  15. router:['vue-router']
  16. },
  17. //allargumentsaretreatedasbooleans
  18. boolean:true
  19. })

minimist[14]

简单说,这个库,就是解析命令行参数的。看例子,我们比较容易看懂传参和解析结果。

  1. $nodeexample/parse.js-abeep-bboop
  2. {_:[],a:'beep',b:'boop'}
  3. $nodeexample/parse.js-x3-y4-n5-abc--beep=boopfoobarbaz
  4. {_:['foo','bar','baz'],
  5. x:3,
  6. y:4,
  7. n:5,
  8. a:true,
  9. b:true,
  10. c:true,
  11. beep:'boop'}

比如

  1. npminitvue@next--vuex--force

4.2 如果设置了 feature flags 跳过 prompts 询问

这种写法方便代码测试等。直接跳过交互式询问,同时也可以省时间。

  1. //ifanyofthefeatureflagsisset,wewouldskipthefeatureprompts
  2. //use`??`insteadof`||`oncewedropNode.js12support
  3. constisFeatureFlagsUsed=
  4. typeof(argv.default||argv.ts||argv.jsx||argv.router||argv.vuex||argv.tests)===
  5. 'boolean'
  6. //生成目录
  7. lettargetDir=argv._[0]
  8. //默认vue-projects
  9. constdefaultProjectName=!targetDir?'vue-project':targetDir
  10. //强制重写文件夹,当同名文件夹存在时
  11. constforceOverwrite=argv.force

4.3 交互式询问一些配置

如上文npm init vue@next 初始化的图示

  • 输入项目名称
  • 还有是否删除已经存在的同名目录
  • 询问使用需要 JSX Router vuex cypress 等。
  1. letresult={}
  2. try{
  3. //Prompts:
  4. //-Projectname:
  5. //-whethertooverwritetheexistingdirectoryornot?
  6. //-enteravalidpackagenameforpackage.json
  7. //-Projectlanguage:JavaScript/TypeScript
  8. //-AddJSXSupport?
  9. //-InstallVueRouterforSPAdevelopment?
  10. //-InstallVuexforstatemanagement?(TODO)
  11. //-AddCypressfortesting?
  12. result=awaitprompts(
  13. [
  14. {
  15. name:'projectName',
  16. type:targetDir?null:'text',
  17. message:'Projectname:',
  18. initial:defaultProjectName,
  19. onState:(state)=>(targetDir=String(state.value).trim()||defaultProjectName)
  20. },
  21. //省略若干配置
  22. {
  23. name:'needsTests',
  24. type:()=>(isFeatureFlagsUsed?null:'toggle'),
  25. message:'AddCypressfortesting?',
  26. initial:false,
  27. active:'Yes',
  28. inactive:'No'
  29. }
  30. ],
  31. {
  32. onCancel:()=>{
  33. thrownewError(red('✖')+'Operationcancelled')
  34. }
  35. }
  36. ]
  37. )
  38. }catch(cancelled){
  39. console.log(cancelled.message)
  40. //退出当前进程。
  41. process.exit(1)
  42. }

4.4 初始化询问用户给到的参数,同时也会给到默认值

  1. //`initial`won'ttakeeffectiftheprompttypeisnull
  2. //sowestillhavetoassignthedefaultvalueshere
  3. const{
  4. packageName=toValidPackageName(defaultProjectName),
  5. shouldOverwrite,
  6. needsJsx=argv.jsx,
  7. needsTypeScript=argv.typescript,
  8. needsRouter=argv.router,
  9. needsVuex=argv.vuex,
  10. needsTests=argv.tests
  11. }=result
  12. constroot=path.join(cwd,targetDir)
  13. //如果需要强制重写,清空文件夹
  14. if(shouldOverwrite){
  15. emptyDir(root)
  16. //如果不存在文件夹,则创建
  17. }elseif(!fs.existsSync(root)){
  18. fs.mkdirSync(root)
  19. }
  20. //脚手架项目目录
  21. console.log(`\nScaffoldingprojectin${root}...`)
  22. //生成package.json文件
  23. constpkg={name:packageName,version:'0.0.0'}
  24. fs.writeFileSync(path.resolve(root,'package.json'),JSON.stringify(pkg,null,2))

4.5 根据模板文件生成初始化项目所需文件

  1. //todo:
  2. //workaroundtheesbuildissuethat`import.meta.url`cannotbecorrectlytranspiled
  3. //whenbundlingfornodeandtheformatiscjs
  4. //consttemplateRoot=newURL('./template',import.meta.url).pathname
  5. consttemplateRoot=path.resolve(__dirname,'template')
  6. constrender=functionrender(templateName){
  7. consttemplateDir=path.resolve(templateRoot,templateName)
  8. renderTemplate(templateDir,root)
  9. }
  10. //Renderbasetemplate
  11. render('base')
  12. //添加配置
  13. //Addconfigs.
  14. if(needsJsx){
  15. render('config/jsx')
  16. }
  17. if(needsRouter){
  18. render('config/router')
  19. }
  20. if(needsVuex){
  21. render('config/vuex')
  22. }
  23. if(needsTests){
  24. render('config/cypress')
  25. }
  26. if(needsTypeScript){
  27. render('config/typescript')
  28. }

4.6 渲染生成代码模板

  1. //Rendercodetemplate.
  2. //prettier-ignore
  3. constcodeTemplate=
  4. (needsTypeScript?'typescript-':'')+
  5. (needsRouter?'router':'default')
  6. render(`code/${codeTemplate}`)
  7. //Renderentryfile(main.js/ts).
  8. if(needsVuex&&needsRouter){
  9. render('entry/vuex-and-router')
  10. }elseif(needsVuex){
  11. render('entry/vuex')
  12. }elseif(needsRouter){
  13. render('entry/router')
  14. }else{
  15. render('entry/default')
  16. }

4.7 如果配置了需要 ts

重命名所有的 .js 文件改成 .ts。重命名 jsconfig.json 文件为 tsconfig.json 文件。

jsconfig.json[15] 是VSCode的配置文件,可用于配置跳转等。

把index.html 文件里的 main.js 重命名为 main.ts。

  1. //Cleanup.
  2. if(needsTypeScript){
  3. //renameall`.js`filesto`.ts`
  4. //renamejsconfig.jsontotsconfig.json
  5. preOrderDirectoryTraverse(
  6. root,
  7. ()=>{},
  8. (filepath)=>{
  9. if(filepath.endsWith('.js')){
  10. fs.renameSync(filepath,filepath.replace(/\.js$/,'.ts'))
  11. }elseif(path.basename(filepath)==='jsconfig.json'){
  12. fs.renameSync(filepath,filepath.replace(/jsconfig\.json$/,'tsconfig.json'))
  13. }
  14. }
  15. )
  16. //Renameentryin`index.html`
  17. constindexHtmlPath=path.resolve(root,'index.html')
  18. constindexHtmlContent=fs.readFileSync(indexHtmlPath,'utf8')
  19. fs.writeFileSync(indexHtmlPath,indexHtmlContent.replace('src/main.js','src/main.ts'))
  20. }

4.8 配置了不需要测试

因为所有的模板都有测试文件,所以不需要测试时,执行删除 cypress、/__tests__/ 文件夹

  1. if(!needsTests){
  2. //Alltemplatesassumestheneedoftests.
  3. //Iftheuserdoesn'tneedit:
  4. //rm-rfcypress**/__tests__/
  5. preOrderDirectoryTraverse(
  6. root,
  7. (dirpath)=>{
  8. constdirname=path.basename(dirpath)
  9. if(dirname==='cypress'||dirname==='__tests__'){
  10. emptyDir(dirpath)
  11. fs.rmdirSync(dirpath)
  12. }
  13. },
  14. ()=>{}
  15. )
  16. }

4.9 根据使用的 npm / yarn / pnpm 生成README.md 文件,给出运行项目的提示

  1. //Instructions:
  2. //Supportedpackagemanagers:pnpm>yarn>npm
  3. //Note:until<https://github.com/pnpm/pnpm/issues/3505>isresolved,
  4. //itisnotpossibletotellifthecommandiscalledby`pnpminit`.
  5. constpackageManager=/pnpm/.test(process.env.npm_execpath)
  6. ?'pnpm'
  7. :/yarn/.test(process.env.npm_execpath)
  8. ?'yarn'
  9. :'npm'
  10. //READMEgeneration
  11. fs.writeFileSync(
  12. path.resolve(root,'README.md'),
  13. generateReadme({
  14. projectName:result.projectName||defaultProjectName,
  15. packageManager,
  16. needsTypeScript,
  17. needsTests
  18. })
  19. )
  20. console.log(`\nDone.Nowrun:\n`)
  21. if(root!==cwd){
  22. console.log(`${bold(green(`cd${path.relative(cwd,root)}`))}`)
  23. }
  24. console.log(`${bold(green(getCommand(packageManager,'install')))}`)
  25. console.log(`${bold(green(getCommand(packageManager,'dev')))}`)
  26. console.log()

5. npm run test => node test.js 测试

  1. //create-vue/test.js
  2. importfsfrom'fs'
  3. importpathfrom'path'
  4. import{fileURLToPath}from'url'
  5. import{spawnSync}from'child_process'
  6. const__dirname=path.dirname(fileURLToPath(import.meta.url))
  7. constplaygroundDir=path.resolve(__dirname,'./playground/')
  8. for(constprojectNameoffs.readdirSync(playgroundDir)){
  9. if(projectName.endsWith('with-tests')){
  10. console.log(`Runningunittestsin${projectName}`)
  11. constunitTestResult=spawnSync('pnpm',['test:unit:ci'],{
  12. cwd:path.resolve(playgroundDir,projectName),
  13. stdio:'inherit',
  14. shell:true
  15. })
  16. if(unitTestResult.status!==0){
  17. thrownewError(`Unittestsfailedin${projectName}`)
  18. }
  19. console.log(`Runninge2etestsin${projectName}`)
  20. conste2eTestResult=spawnSync('pnpm',['test:e2e:ci'],{
  21. cwd:path.resolve(playgroundDir,projectName),
  22. stdio:'inherit',
  23. shell:true
  24. })
  25. if(e2eTestResult.status!==0){
  26. thrownewError(`E2Etestsfailedin${projectName}`)
  27. }
  28. }
  29. }

主要对生成快照时生成的在 playground 32个文件夹,进行如下测试。

  1. pnpmtest:unit:ci
  2. pnpmtest:e2e:ci

6. 总结

我们使用了快如闪电般的npm init vue@next,学习npx命令了。学会了其原理。

  1. 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