You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
#!/usr/bin/env node
constprogram=require('commander')constcreate=require('../lib/create')program.version('0.1.0').command('create <name>').description('create a new project').action(name=>{create(name)})program.parse()
它使用 commander 注册了一个 create 命令,并设置了脚手架的版本和描述。我将这段代码保存在项目下的 bin 目录,并命名为 mvc.js。然后在 package.json 文件添加这段代码:
constprompts=[{"name": "features",// 选项名称"message": "Check the features needed for your project:",// 选项提示语"pageSize": 10,"type": "checkbox",// 选项类型 另外还有 confirm list 等"choices": [// 具体的选项{"name": "Babel","value": "babel","short": "Babel","description": "Transpile modern JavaScript to older versions (for compatibility)","link": "https://babeljs.io/","checked": true},{"name": "Router","value": "router","description": "Structure the app with dynamic pages","link": "https://router.vuejs.org/"},]}]inquirer.prompt(prompts)
{name: 'Router',value: 'router',description: 'Structure the app with dynamic pages',link: 'https://router.vuejs.org/',},{name: 'historyMode',when: answers=>answers.features.includes('router'),type: 'confirm',message: `Use history mode for router? ${chalk.yellow(`(Requires proper server setup for index fallback in production)`)}`,description: `By using the HTML5 History API, the URLs don't need the '#' character anymore.`,link: 'https://router.vuejs.org/guide/essentials/history-mode.html',},
第二个问题中有一个属性 when,它的值是一个函数 answers => answers.features.includes('router')。当函数的执行结果为 true,第二个问题才会显示出来。如果你在上一个问题中选择了 router,它的结果就会变为 true。弹出第二个问题:问你路由模式是否选择 history 模式。
// craete.jsconstcreator=newCreator()// 获取各个模块的交互提示语constpromptModules=getPromptModules()constpromptAPI=newPromptModuleAPI(creator)promptModules.forEach(m=>m(promptAPI))// 清空控制台clearConsole()// 弹出交互提示语并获取用户的选择constanswers=awaitinquirer.prompt(creator.getFinalPrompts())functiongetPromptModules(){return['babel','router','vuex','linter',].map(file=>require(`./promptModules/${file}`))}// Creator.jsclassCreator{constructor(){this.featurePrompt={name: 'features',message: 'Check the features needed for your project:',pageSize: 10,type: 'checkbox',choices: [],}this.injectedPrompts=[]}getFinalPrompts(){this.injectedPrompts.forEach(prompt=>{constoriginalWhen=prompt.when||(()=>true)prompt.when=answers=>originalWhen(answers)})constprompts=[this.featurePrompt,
...this.injectedPrompts,]returnprompts}}module.exports=Creator// PromptModuleAPI.jsmodule.exports=classPromptModuleAPI{constructor(creator){this.creator=creator}injectFeature(feature){this.creator.featurePrompt.choices.push(feature)}injectPrompt(prompt){this.creator.injectedPrompts.push(prompt)}}
module.exports=(generator)=>{// 向入口文件 `src/main.js` 注入代码 import store from './store'generator.injectImports(generator.entryFile,`import store from './store'`)// 向入口文件 `src/main.js` 的 new Vue() 注入选项 storegenerator.injectRootOptions(generator.entryFile,`store`)// 注入依赖generator.extendPackage({dependencies: {vuex: '^3.6.2',},})// 渲染模板generator.render('./template',{})}
// 向入口文件 `src/main.js` 注入代码 import store from './store'generator.injectImports(generator.entryFile,`import store from './store'`)// 向入口文件 `src/main.js` 的 new Vue() 注入选项 storegenerator.injectRootOptions(generator.entryFile,`store`)
constPACKAGE_MANAGER_CONFIG={npm: {install: ['install'],},yarn: {install: [],},}awaitexecuteCommand(this.bin,// 'yarn' or 'npm'[
...PACKAGE_MANAGER_CONFIG[this.bin][command],
...(args||[]),],this.context,)
切换 npm 源
当用户选择了项目功能后,会先调用 shouldUseTaobao() 方法判断是否需要切换淘宝源:
constexeca=require('execa')constchalk=require('chalk')constrequest=require('./request')const{ hasYarn }=require('./env')constinquirer=require('inquirer')constregistries=require('./registries')const{ loadOptions, saveOptions }=require('./options')asyncfunctionping(registry){awaitrequest.get(`${registry}/vue-cli-version-marker/latest`)returnregistry}functionremoveSlash(url){returnurl.replace(/\/$/,'')}letcheckedletresultmodule.exports=asyncfunctionshouldUseTaobao(command){if(!command){command=hasYarn() ? 'yarn' : 'npm'}// ensure this only gets called once.if(checked)returnresultchecked=true// previously saved preferenceconstsaved=loadOptions().useTaobaoRegistryif(typeofsaved==='boolean'){return(result=saved)}constsave=val=>{result=valsaveOptions({useTaobaoRegistry: val})returnval}letuserCurrenttry{userCurrent=(awaitexeca(command,['config','get','registry'])).stdout}catch(registryError){try{// Yarn 2 uses `npmRegistryServer` instead of `registry`userCurrent=(awaitexeca(command,['config','get','npmRegistryServer'])).stdout}catch(npmRegistryServerError){returnsave(false)}}constdefaultRegistry=registries[command]if(removeSlash(userCurrent)!==removeSlash(defaultRegistry)){// user has configured custom registry, respect thatreturnsave(false)}letfastertry{faster=awaitPromise.race([ping(defaultRegistry),ping(registries.taobao),])}catch(e){returnsave(false)}if(faster!==registries.taobao){// default is already fasterreturnsave(false)}if(process.env.VUE_CLI_API_MODE){returnsave(true)}// ask and save preferenceconst{ useTaobaoRegistry }=awaitinquirer.prompt([{name: 'useTaobaoRegistry',type: 'confirm',message: chalk.yellow(` Your connection to the default ${command} registry seems to be slow.\n`+` Use ${chalk.cyan(registries.taobao)} for faster installation?`,),},])// 注册淘宝源if(useTaobaoRegistry){awaitexeca(command,['config','set','registry',registries.taobao])}returnsave(useTaobaoRegistry)}
constotherPrompts=[{name: 'save',when: isManualMode,type: 'confirm',message: 'Save this as a preset for future projects?',default: false,},{name: 'saveName',when: answers=>answers.save,type: 'input',message: 'Save preset as:',},]
保存配置的代码为:
exports.saveOptions=(toSave)=>{constoptions=Object.assign(cloneDeep(exports.loadOptions()),toSave)for(constkeyinoptions){if(!(keyinexports.defaults)){deleteoptions[key]}}cachedOptions=optionstry{fs.writeFileSync(rcPath,JSON.stringify(options,null,2))returntrue}catch(e){error(`Error saving preferences: `+`make sure you have write access to ${rcPath}.\n`+`(${e.message})`,)}}exports.savePreset=(name,preset)=>{constpresets=cloneDeep(exports.loadOptions().presets||{})presets[name]=presetreturnexports.saveOptions({ presets })}
最近在学习 vue-cli 的源码,获益良多。为了让自己理解得更加深刻,我决定模仿它造一个轮子,争取尽可能多的实现原有的功能。
我将这个轮子分成三个版本:
有人可能不懂脚手架是什么。按我的理解,脚手架就是帮助你把项目的基础架子搭好。例如项目依赖、模板、构建工具等等。让你不用从零开始配置一个项目,尽可能快的进行业务开发。
建议在阅读本文时,能够结合项目源码一起配合使用,效果更好。这是项目地址 mini-cli。项目中的每一个分支都对应一个版本,例如第一个版本对应的 git 分支为 v1。所以在阅读源码时,记得要切换到对应的分支。
第一个版本 v1
第一个版本的功能比较简单,大致为:
package.json
文件,并添加对应的依赖项。index.html
、main.js
、App.vue
等文件)。npm install
命令安装依赖。项目目录树:
处理用户命令
脚手架第一个功能就是处理用户的命令,这需要使用 commander.js。这个库的功能就是解析用户的命令,提取出用户的输入交给脚手架。例如这段代码:
它使用 commander 注册了一个
create
命令,并设置了脚手架的版本和描述。我将这段代码保存在项目下的bin
目录,并命名为mvc.js
。然后在package.json
文件添加这段代码:再执行 npm link,就可以将
mvc
注册成全局命令。这样在电脑上的任何地方都能使用mvc
命令了。实际上,就是用mvc
命令来代替执行node ./bin/mvc.js
。假设用户在命令行上输入
mvc create demo
(实际上执行的是node ./bin/mvc.js create demo
),commander
解析到命令create
和参数demo
。然后脚手架可以在action
回调里取到参数name
(值为 demo)。和用户交互
取到用户要创建的项目名称
demo
之后,就可以弹出交互选项,询问用户要创建的项目需要哪些功能。这需要用到Inquirer.js。
Inquirer.js
的功能就是弹出一个问题和一些选项,让用户选择。并且选项可以指定是多选、单选等等。例如下面的代码:
弹出的问题和选项如下:
问题的类型
"type": "checkbox"
是checkbox
说明是多选。如果两个选项都进行选中的话,返回来的值为:其中
features
是上面问题中的name
属性。features
数组中的值则是每个选项中的value
。Inquirer.js
还可以提供具有相关性的问题,也就是上一个问题选择了指定的选项,下一个问题才会显示出来。例如下面的代码:第二个问题中有一个属性
when
,它的值是一个函数answers => answers.features.includes('router')
。当函数的执行结果为true
,第二个问题才会显示出来。如果你在上一个问题中选择了router
,它的结果就会变为true
。弹出第二个问题:问你路由模式是否选择history
模式。大致了解
Inquirer.js
后,就可以明白这一步我们要做什么了。主要就是将脚手架支持的功能配合对应的问题、可选值在控制台上展示出来,供用户选择。获取到用户具体的选项值后,再渲染模板和依赖。有哪些功能
先来看一下第一个版本支持哪些功能:
由于这是一个 vue 相关的脚手架,所以 vue 是默认提供的,不需要用户选择。另外构建工具 webpack 提供了开发环境和打包的功能,也是必需的,不用用户进行选择。所以可供用户选择的功能只有 4 个:
现在我们先来看一下这 4 个功能对应的交互提示语相关的文件。它们全部放在
lib/promptModules
目录下:每个文件包含了和它相关的所有交互式问题。例如刚才的示例,说明
router
相关的问题有两个。下面再看一下babel.js
的代码:只有一个问题,就是问下用户需不需要
babel
功能,默认为checked: true
,也就是需要。注入问题
用户使用
create
命令后,脚手架需要将所有功能的交互提示语句聚合在一起:以上代码的逻辑如下:
creator
对象getPromptModules()
获取所有功能的交互提示语PromptModuleAPI
将所有交互提示语注入到creator
对象const answers = await inquirer.prompt(creator.getFinalPrompts())
在控制台弹出交互语句,并将用户选择结果赋值给answers
变量。如果所有功能都选上,
answers
的值为:项目模板
获取用户的选项后就该开始渲染模板和生成
package.json
文件了。先来看一下如何生成package.json
文件:先定义一个
pkg
变量来表示package.json
文件,并设定一些默认值。所有的项目模板都放在
lib/generator
目录下:每个模板的功能都差不多:
pkg
变量注入依赖项注入依赖
下面是
babel
相关的代码:可以看到,模板调用
generator
对象的extendPackage()
方法向pkg
变量注入了babel
相关的所有依赖。注入依赖的过程就是遍历所有用户已选择的模板,并调用
extendPackage()
注入依赖。渲染模板
脚手架是怎么渲染模板的呢?用
vuex
举例,先看一下它的代码:可以看到渲染的代码为
generator.render('./template', {})
。./template
是模板目录的路径:所有的模板代码都放在
template
目录下,vuex
将会在用户创建的目录下的src
目录生成store
文件夹,里面有一个index.js
文件。它的内容为:这里简单描述一下
generator.render()
的渲染过程。第一步, 使用 globby 读取模板目录下的所有文件:
第二步,遍历所有读取的文件。如果文件是二进制文件,则不作处理,渲染时直接生成文件。否则读取文件内容,再调用 ejs 进行渲染:
使用
ejs
的好处,就是可以结合变量来决定是否渲染某些代码。例如webpack
的模板中有这样一段代码:ejs
可以根据用户是否选择了babel
来决定是否渲染这段代码。如果hasBabel
为false
,则这段代码:将不会被渲染出来。
hasBabel
的值是调用render()
时用参数传过去的:第三步,注入特定代码。回想一下刚才
vuex
中的:这两行代码的作用是:在项目入口文件
src/main.js
中注入特定的代码。vuex
是vue
的一个状态管理库,属于vue
全家桶中的一员。如果创建的项目没有选择vuex
和vue-router
。则src/main.js
的代码为:如果选择了
vuex
,它会注入上面所说的两行代码,现在src/main.js
代码变为:这里简单描述一下代码的注入过程:
提取
package.json
的部分选项一些第三方库的配置项可以放在
package.json
文件,也可以自己独立生成一份文件。例如babel
在package.json
中注入的配置为:我们可以调用
generator.extractConfigFiles()
将内容提取出来并生成babel.config.js
文件:生成文件
渲染好的模板文件和
package.json
文件目前还是在内存中,并没有真正的在硬盘上创建。这时可以调用writeFileTree()
将文件生成:这段代码的逻辑如下:
例如现在一个文件路径为
src/test.js
,第一次写入时,由于还没有src
目录。所以会先生成src
目录,再生成test.js
文件。webpack
webpack 需要提供开发环境下的热加载、编译等服务,还需要提供打包服务。目前 webpack 的代码比较少,功能比较简单。而且生成的项目中,webpack 配置代码是暴露出来的。这留待 v3 版本再改进。
添加新功能
添加一个新功能,需要在两个地方添加代码:分别是
lib/promptModules
和lib/generator
。在lib/promptModules
中添加的是这个功能相关的交互提示语。在lib/generator
中添加的是这个功能相关的依赖和模板代码。不过不是所有的功能都需要添加模板代码的,例如
babel
就不需要。在添加新功能时,有可能会对已有的模板代码造成影响。例如我现在需要项目支持ts
。除了添加ts
相关的依赖,还得在webpack
vue
vue-router
vuex
linter
等功能中修改原有的模板代码。举个例子,在
vue-router
中,如果支持ts
,则这段代码:需要修改为:
因为
ts
的值有类型。总之,添加的新功能越多,各个功能的模板代码也会越来越多。并且还需要考虑到各个功能之间的影响。
下载依赖
下载依赖需要使用 execa,它可以调用子进程执行命令。
调用
executeCommand()
开始下载依赖,参数为npm install
和用户创建的项目路径。为了能让用户看到下载依赖的过程,我们需要使用下面的代码将子进程的输出传给主进程,也就是输出到控制台:下面我用动图演示一下 v1 版本的创建过程:
创建成功的项目截图:
第二个版本 v2
第二个版本在 v1 的基础上添加了一些辅助功能:
覆盖和合并
创建项目时,先提前判断一下该项目是否存在:
如果选择
overwrite
,则进行移除fs.remove(targetDir)
。默认配置和手动模式
先在代码中提前把默认配置的代码写好:
这个配置默认使用
babel
和eslint
。然后生成交互提示语时,先调用
getDefaultPrompts()
方法获取默认配置。这样配置后,在用户选择功能前会先弹出这样的提示语:
包管理器
在
vue-cli
创建项目时,会生成一个.vuerc
文件,里面会记录一些关于项目的配置信息。例如使用哪个包管理器、npm 源是否使用淘宝源等等。为了避免和vue-cli
冲突,本脚手架生成的配置文件为.mvcrc
。这个
.mvcrc
文件保存在用户的home
目录下(不同操作系统目录不同)。我的是 win10 操作系统,保存目录为C:\Users\bin
。获取用户的home
目录可以通过以下代码获取:.mvcrc
文件还会保存用户创建项目的配置,这样当用户重新创建项目时,就可以直接选择以前创建过的配置,不用再一步步的选择项目功能。在第一次创建项目时,
.mvcrc
文件是不存在的。如果这时用户还安装了 yarn,脚手架就会提示用户要使用哪个包管理器:当用户选择 yarn 后,下载依赖的命令就会变为
yarn
;如果选择了 npm,下载命令则为npm install
:切换 npm 源
当用户选择了项目功能后,会先调用
shouldUseTaobao()
方法判断是否需要切换淘宝源:上面代码的逻辑为:
.mvcrc
是否有useTaobaoRegistry
选项。如果有,直接将结果返回,无需判断。get
请求,通过Promise.race()
来调用。这样更快的那个请求会先返回,从而知道是默认源还是淘宝源速度更快。await execa(command, ['config', 'set', 'registry', registries.taobao])
将当前 npm 的源改为淘宝源,即npm config set registry https://registry.npm.taobao.org
。如果是 yarn,则命令为yarn config set registry https://registry.npm.taobao.org
。一点疑问
其实
vue-cli
是没有这段代码的:这是我自己加的。主要是我没有在
vue-cli
中找到显式注册淘宝源的代码,它只是从配置文件读取出是否使用淘宝源,或者将是否使用淘宝源这个选项写入配置文件。另外 npm 的配置文件.npmrc
是可以更改默认源的,如果在.npmrc
文件直接写入淘宝的镜像地址,那 npm 就会使用淘宝源下载依赖。但 npm 肯定不会去读取.vuerc
的配置来决定是否使用淘宝源。对于这一点我没搞明白,所以在用户选择了淘宝源之后,手动调用命令注册一遍。
将项目功能保存为默认配置
如果用户创建项目时选择手动模式,在选择完一系列功能后,会弹出下面的提示语:
询问用户是否将这次的项目选择保存为默认配置,如果用户选择是,则弹出下一个提示语:
让用户输入保存配置的名称。
这两句提示语相关的代码为:
保存配置的代码为:
以上代码直接将用户的配置保存到
.mvcrc
文件中。下面是我电脑上的.mvcrc
的内容:下次再创建项目时,脚手架就会先读取这个配置文件的内容,让用户决定是否使用已有的配置来创建项目。
至此,v2 版本的内容就介绍完了。
小结
由于
vue-cli
关于插件的源码我还没有看完,所以这篇文章只讲解前两个版本的源码。v3 版本等我看完vue-cli
的源码再回来填坑,预计在 3 月初就可以完成。如果你想了解更多关于前端工程化的文章,可以看一下我写的《带你入门前端工程》。 这里是全文目录:
参考资料
The text was updated successfully, but these errors were encountered: