手把手教你写一个脚手架(上)

简介: 手把手教你写一个脚手架

最近在学习 vue-cli 的源码,获益良多。为了让自己理解得更加深刻,我决定模仿它造一个轮子,争取尽可能多的实现原有的功能。

我将这个轮子分成三个版本:

  1. 尽可能用最少的代码实现一个最简版本的脚手架。
  2. 在 1 的基础上添加一些辅助功能,例如选择包管理器、npm 源等等。
  3. 实现插件化,可以自由的进行扩展。在不影响内部源码的情况下,添加功能。

有人可能不懂脚手架是什么。按我的理解,脚手架就是帮助你把项目的基础架子搭好。例如项目依赖、模板、构建工具等等。让你不用从零开始配置一个项目,尽可能快的进行业务开发。

建议在阅读本文时,能够结合项目源码一起配合使用,效果更好。这是项目地址 mini-cli。项目中的每一个分支都对应一个版本,例如第一个版本对应的 git 分支为 v1。所以在阅读源码时,记得要切换到对应的分支。

第一个版本 v1

第一个版本的功能比较简单,大致为:

  1. 用户输入命令,准备创建项目。
  2. 脚手架解析用户命令,并弹出交互语句,询问用户创建项目需要哪些功能。
  3. 用户选择自己需要的功能。
  4. 脚手架根据用户的选择创建 package.json 文件,并添加对应的依赖项。
  5. 脚手架根据用户的选择渲染项目模板,生成文件(例如 index.htmlmain.jsApp.vue 等文件)。
  6. 执行 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)
    }
}

以上代码的逻辑如下:

  1. 创建 creator 对象
  2. 调用 getPromptModules() 获取所有功能的交互提示语
  3. 再调用 PromptModuleAPI 将所有交互提示语注入到 creator 对象
  4. 通过 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 模板

每个模板的功能都差不多:

  1. pkg 变量注入依赖项
  2. 提供模板文件

注入依赖

下面是 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() 注入依赖。

目录
相关文章
|
资源调度 监控 前端开发
手把手教你写一个脚手架(下)
手把手教你写一个脚手架(下)
80 0
|
前端开发 JavaScript 测试技术
手把手带你入门前端工程化——超详细教程(一)
手把手带你入门前端工程化——超详细教程
316 0
|
5月前
|
JavaScript 前端开发 编译器
Vue快速上手笔记2 - 开发环境的搭建
Vue快速上手笔记2 - 开发环境的搭建
44 0
|
JavaScript
手把手教你写一个脚手架(中)
手把手教你写一个脚手架(中)
90 0
|
前端开发 Shell 项目管理
手把手教你写一个脚手架(二)
手把手教你写一个脚手架(二)
53 0
|
监控 前端开发 测试技术
手把手带你入门前端工程化——超详细教程(二)
手把手带你入门前端工程化——超详细教程(二)
81 0
|
监控 前端开发 JavaScript
手把手带你入门前端工程化——超详细教程(三)
手把手带你入门前端工程化——超详细教程(三)
103 0
|
Web App开发 监控 前端开发
手把手带你入门前端工程化——超详细教程(四)
手把手带你入门前端工程化——超详细教程(四)
135 0
|
JavaScript
vue项目创建手把手教会绝不迷路(赞赞赞)
vue项目创建手把手教会绝不迷路(赞赞赞)
48 0
|
前端开发 开发工具 git
前端脚手架的搭建
前端脚手架的搭建
174 0