最近在学习 vue-cli 的源码,获益良多。为了让自己理解得更加深刻,我决定模仿它造一个轮子,争取尽可能多的实现原有的功能。
我将这个轮子分成三个版本:
- 尽可能用最少的代码实现一个最简版本的脚手架。
- 在 1 的基础上添加一些辅助功能,例如选择包管理器、npm 源等等。
- 实现插件化,可以自由的进行扩展。在不影响内部源码的情况下,添加功能。
有人可能不懂脚手架是什么。按我的理解,脚手架就是帮助你把项目的基础架子搭好。例如项目依赖、模板、构建工具等等。让你不用从零开始配置一个项目,尽可能快的进行业务开发。
建议在阅读本文时,能够结合项目源码一起配合使用,效果更好。这是项目地址 mini-cli。项目中的每一个分支都对应一个版本,例如第一个版本对应的 git 分支为 v1。所以在阅读源码时,记得要切换到对应的分支。
第一个版本 v1
第一个版本的功能比较简单,大致为:
- 用户输入命令,准备创建项目。
- 脚手架解析用户命令,并弹出交互语句,询问用户创建项目需要哪些功能。
- 用户选择自己需要的功能。
- 脚手架根据用户的选择创建
package.json
文件,并添加对应的依赖项。 - 脚手架根据用户的选择渲染项目模板,生成文件(例如
index.html
、main.js
、App.vue
等文件)。 - 执行
npm install
命令安装依赖。
项目目录树:
├─.vscode ├─bin │ ├─mvc.js # mvc 全局命令 ├─lib │ ├─generator # 各个功能的模板 │ │ ├─babel # babel 模板 │ │ ├─linter # eslint 模板 │ │ ├─router # vue-router 模板 │ │ ├─vue # vue 模板 │ │ ├─vuex # vuex 模板 │ │ └─webpack # webpack 模板 │ ├─promptModules # 各个模块的交互提示语 │ └─utils # 一系列工具函数 │ ├─create.js # create 命令处理函数 │ ├─Creator.js # 处理交互提示 │ ├─Generator.js # 渲染模板 │ ├─PromptModuleAPI.js # 将各个功能的提示语注入 Creator └─scripts # commit message 验证脚本 和项目无关 不需关注
处理用户命令
脚手架第一个功能就是处理用户的命令,这需要使用 commander.js。这个库的功能就是解析用户的命令,提取出用户的输入交给脚手架。例如这段代码:
#!/usr/bin/env node const program = require('commander') const create = 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
文件添加这段代码:
"bin": { "mvc": "./bin/mvc.js" },
再执行 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](https://github.com/SBoudrias/...。Inquirer.js
的功能就是弹出一个问题和一些选项,让用户选择。并且选项可以指定是多选、单选等等。
例如下面的代码:
const prompts = [ { "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)
弹出的问题和选项如下:
问题的类型 "type": "checkbox"
是 checkbox
说明是多选。如果两个选项都进行选中的话,返回来的值为:
{ features: ['babel', 'router'] }
其中 features
是上面问题中的 name
属性。features
数组中的值则是每个选项中的 value
。
Inquirer.js
还可以提供具有相关性的问题,也就是上一个问题选择了指定的选项,下一个问题才会显示出来。例如下面的代码:
{ 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
模式。
大致了解 Inquirer.js
后,就可以明白这一步我们要做什么了。主要就是将脚手架支持的功能配合对应的问题、可选值在控制台上展示出来,供用户选择。获取到用户具体的选项值后,再渲染模板和依赖。
有哪些功能
先来看一下第一个版本支持哪些功能:
- vue
- vue-router
- vuex
- babel
- webpack
- linter(eslint)
由于这是一个 vue 相关的脚手架,所以 vue 是默认提供的,不需要用户选择。另外构建工具 webpack 提供了开发环境和打包的功能,也是必需的,不用用户进行选择。所以可供用户选择的功能只有 4 个:
- vue-router
- vuex
- babel
- linter
现在我们先来看一下这 4 个功能对应的交互提示语相关的文件。它们全部放在 lib/promptModules
目录下:
-babel.js -linter.js -router.js -vuex.js
每个文件包含了和它相关的所有交互式问题。例如刚才的示例,说明 router
相关的问题有两个。下面再看一下 babel.js
的代码:
module.exports = (api) => { api.injectFeature({ name: 'Babel', value: 'babel', short: 'Babel', description: 'Transpile modern JavaScript to older versions (for compatibility)', link: 'https://babeljs.io/', checked: true, }) }
只有一个问题,就是问下用户需不需要 babel
功能,默认为 checked: true
,也就是需要。
注入问题
用户使用 create
命令后,脚手架需要将所有功能的交互提示语句聚合在一起:
// craete.js const creator = new Creator() // 获取各个模块的交互提示语 const promptModules = getPromptModules() const promptAPI = new PromptModuleAPI(creator) promptModules.forEach(m => m(promptAPI)) // 清空控制台 clearConsole() // 弹出交互提示语并获取用户的选择 const answers = await inquirer.prompt(creator.getFinalPrompts()) function getPromptModules() { return [ 'babel', 'router', 'vuex', 'linter', ].map(file => require(`./promptModules/${file}`)) } // Creator.js class Creator { 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 => { const originalWhen = prompt.when || (() => true) prompt.when = answers => originalWhen(answers) }) const prompts = [ this.featurePrompt, ...this.injectedPrompts, ] return prompts } } module.exports = Creator // PromptModuleAPI.js module.exports = class PromptModuleAPI { constructor(creator) { this.creator = creator } injectFeature(feature) { this.creator.featurePrompt.choices.push(feature) } injectPrompt(prompt) { this.creator.injectedPrompts.push(prompt) } }
以上代码的逻辑如下:
- 创建
creator
对象 - 调用
getPromptModules()
获取所有功能的交互提示语 - 再调用
PromptModuleAPI
将所有交互提示语注入到creator
对象 - 通过
const answers = await inquirer.prompt(creator.getFinalPrompts())
在控制台弹出交互语句,并将用户选择结果赋值给answers
变量。
如果所有功能都选上,answers
的值为:
{ features: [ 'vue', 'webpack', 'babel', 'router', 'vuex', 'linter' ], // 项目具有的功能 historyMode: true, // 路由是否使用 history 模式 eslintConfig: 'airbnb', // esilnt 校验代码的默认规则,可被覆盖 lintOn: [ 'save' ] // 保存代码时进行校验 }
项目模板
获取用户的选项后就该开始渲染模板和生成 package.json
文件了。先来看一下如何生成 package.json
文件:
// package.json 文件内容 const pkg = { name, version: '0.1.0', dependencies: {}, devDependencies: {}, }
先定义一个 pkg
变量来表示 package.json
文件,并设定一些默认值。
所有的项目模板都放在 lib/generator
目录下:
├─lib │ ├─generator # 各个功能的模板 │ │ ├─babel # babel 模板 │ │ ├─linter # eslint 模板 │ │ ├─router # vue-router 模板 │ │ ├─vue # vue 模板 │ │ ├─vuex # vuex 模板 │ │ └─webpack # webpack 模板
每个模板的功能都差不多:
- 向
pkg
变量注入依赖项 - 提供模板文件
注入依赖
下面是 babel
相关的代码:
module.exports = (generator) => { generator.extendPackage({ babel: { presets: ['@babel/preset-env'], }, dependencies: { 'core-js': '^3.8.3', }, devDependencies: { '@babel/core': '^7.12.13', '@babel/preset-env': '^7.12.13', 'babel-loader': '^8.2.2', }, }) }
可以看到,模板调用 generator
对象的 extendPackage()
方法向 pkg
变量注入了 babel
相关的所有依赖。
extendPackage(fields) { const pkg = this.pkg for (const key in fields) { const value = fields[key] const existing = pkg[key] if (isObject(value) && (key === 'dependencies' || key === 'devDependencies' || key === 'scripts')) { pkg[key] = Object.assign(existing || {}, value) } else { pkg[key] = value } } }
注入依赖的过程就是遍历所有用户已选择的模板,并调用 extendPackage()
注入依赖。