Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

手把手教你写一个脚手架 #22

Open
woai3c opened this issue Feb 24, 2021 · 3 comments
Open

手把手教你写一个脚手架 #22

woai3c opened this issue Feb 24, 2021 · 3 comments

Comments

@woai3c
Copy link
Owner

woai3c commented Feb 24, 2021

最近在学习 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
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() 注入依赖。

渲染模板

脚手架是怎么渲染模板的呢?用 vuex 举例,先看一下它的代码:

module.exports = (generator) => {
	// 向入口文件 `src/main.js` 注入代码 import store from './store'
    generator.injectImports(generator.entryFile, `import store from './store'`)
	
    // 向入口文件 `src/main.js` 的 new Vue() 注入选项 store
    generator.injectRootOptions(generator.entryFile, `store`)
	
    // 注入依赖
    generator.extendPackage({
        dependencies: {
            vuex: '^3.6.2',
        },
    })
	
    // 渲染模板
    generator.render('./template', {})
}

可以看到渲染的代码为 generator.render('./template', {})./template 是模板目录的路径:

所有的模板代码都放在 template 目录下,vuex 将会在用户创建的目录下的 src 目录生成 store 文件夹,里面有一个 index.js 文件。它的内容为:

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
    state: {
    },
    mutations: {
    },
    actions: {
    },
    modules: {
    },
})

这里简单描述一下 generator.render() 的渲染过程。

第一步, 使用 globby 读取模板目录下的所有文件:

const _files = await globby(['**/*'], { cwd: source, dot: true })

第二步,遍历所有读取的文件。如果文件是二进制文件,则不作处理,渲染时直接生成文件。否则读取文件内容,再调用 ejs 进行渲染:

// 返回文件内容
const template = fs.readFileSync(name, 'utf-8')
return ejs.render(template, data, ejsOptions)

使用 ejs 的好处,就是可以结合变量来决定是否渲染某些代码。例如 webpack 的模板中有这样一段代码:

module: {
      rules: [
          <%_ if (hasBabel) { _%>
          {
              test: /\.js$/,
              loader: 'babel-loader',
              exclude: /node_modules/,
          },
          <%_ } _%>
      ],
  },

ejs 可以根据用户是否选择了 babel 来决定是否渲染这段代码。如果 hasBabelfalse,则这段代码:

{
    test: /\.js$/,
    loader: 'babel-loader',
    exclude: /node_modules/,
},

将不会被渲染出来。hasBabel 的值是调用 render() 时用参数传过去的:

generator.render('./template', {
    hasBabel: options.features.includes('babel'),
    lintOnSave: options.lintOn.includes('save'),
})

第三步,注入特定代码。回想一下刚才 vuex 中的:

// 向入口文件 `src/main.js` 注入代码 import store from './store'
generator.injectImports(generator.entryFile, `import store from './store'`)

// 向入口文件 `src/main.js` 的 new Vue() 注入选项 store
generator.injectRootOptions(generator.entryFile, `store`)

这两行代码的作用是:在项目入口文件 src/main.js 中注入特定的代码。

vuexvue 的一个状态管理库,属于 vue 全家桶中的一员。如果创建的项目没有选择 vuexvue-router。则 src/main.js 的代码为:

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

new Vue({
    render: (h) => h(App),
}).$mount('#app')

如果选择了 vuex,它会注入上面所说的两行代码,现在 src/main.js 代码变为:

import Vue from 'vue'
import store from './store' // 注入的代码
import App from './App.vue'

Vue.config.productionTip = false

new Vue({
  store, // 注入的代码
  render: (h) => h(App),
}).$mount('#app')

这里简单描述一下代码的注入过程:

  1. 使用 vue-codemod 将代码解析成语法抽象树 AST。
  2. 然后将要插入的代码变成 AST 节点插入到上面所说的 AST 中。
  3. 最后将新的 AST 重新渲染成代码。

提取 package.json 的部分选项

一些第三方库的配置项可以放在 package.json 文件,也可以自己独立生成一份文件。例如 babelpackage.json 中注入的配置为:

babel: {
    presets: ['@babel/preset-env'],
}

我们可以调用 generator.extractConfigFiles() 将内容提取出来并生成 babel.config.js 文件:

module.exports = {
    presets: ['@babel/preset-env'],
}

生成文件

渲染好的模板文件和 package.json 文件目前还是在内存中,并没有真正的在硬盘上创建。这时可以调用 writeFileTree() 将文件生成:

const fs = require('fs-extra')
const path = require('path')

module.exports = async function writeFileTree(dir, files) {
    Object.keys(files).forEach((name) => {
        const filePath = path.join(dir, name)
        fs.ensureDirSync(path.dirname(filePath))
        fs.writeFileSync(filePath, files[name])
    })
}

这段代码的逻辑如下:

  1. 遍历所有渲染好的文件,逐一生成。
  2. 在生成一个文件时,确认它的父目录在不在,如果不在,就先生成父目录。
  3. 写入文件。

例如现在一个文件路径为 src/test.js,第一次写入时,由于还没有 src 目录。所以会先生成 src 目录,再生成 test.js 文件。

webpack

webpack 需要提供开发环境下的热加载、编译等服务,还需要提供打包服务。目前 webpack 的代码比较少,功能比较简单。而且生成的项目中,webpack 配置代码是暴露出来的。这留待 v3 版本再改进。

添加新功能

添加一个新功能,需要在两个地方添加代码:分别是 lib/promptModuleslib/generator。在 lib/promptModules 中添加的是这个功能相关的交互提示语。在 lib/generator 中添加的是这个功能相关的依赖和模板代码。

不过不是所有的功能都需要添加模板代码的,例如 babel 就不需要。在添加新功能时,有可能会对已有的模板代码造成影响。例如我现在需要项目支持 ts。除了添加 ts 相关的依赖,还得在 webpack vue vue-router vuex linter 等功能中修改原有的模板代码。

举个例子,在 vue-router 中,如果支持 ts,则这段代码:

const routes = [ // ... ]

需要修改为:

<%_ if (hasTypeScript) { _%>
const routes: Array<RouteConfig> = [ // ... ]
<%_ } else { _%>
const routes = [ // ... ]
<%_ } _%>

因为 ts 的值有类型。

总之,添加的新功能越多,各个功能的模板代码也会越来越多。并且还需要考虑到各个功能之间的影响。

下载依赖

下载依赖需要使用 execa,它可以调用子进程执行命令。

const execa = require('execa')

module.exports = function executeCommand(command, cwd) {
    return new Promise((resolve, reject) => {
        const child = execa(command, [], {
            cwd,
            stdio: ['inherit', 'pipe', 'inherit'],
        })

        child.stdout.on('data', buffer => {
            process.stdout.write(buffer)
        })

        child.on('close', code => {
            if (code !== 0) {
                reject(new Error(`command failed: ${command}`))
                return
            }

            resolve()
        })
    })
}

// create.js 文件
console.log('\n正在下载依赖...\n')
// 下载依赖
await executeCommand('npm install', path.join(process.cwd(), name))
console.log('\n依赖下载完成! 执行下列命令开始开发:\n')
console.log(`cd ${name}`)
console.log(`npm run dev`)

调用 executeCommand() 开始下载依赖,参数为 npm install 和用户创建的项目路径。为了能让用户看到下载依赖的过程,我们需要使用下面的代码将子进程的输出传给主进程,也就是输出到控制台:

child.stdout.on('data', buffer => {
    process.stdout.write(buffer)
})

下面我用动图演示一下 v1 版本的创建过程:

创建成功的项目截图:

第二个版本 v2

第二个版本在 v1 的基础上添加了一些辅助功能:

  1. 创建项目时判断该项目是否已存在,支持覆盖和合并创建。
  2. 选择功能时提供默认配置和手动选择两种模式。
  3. 如果用户的环境同时存在 yarn 和 npm,则会提示用户要使用哪个包管理器。
  4. 如果 npm 的默认源速度比较慢,则提示用户是否要切换到淘宝源。
  5. 如果用户是手动选择功能,在结束后会询问用户是否要将这次的选择保存为默认配置。

覆盖和合并

创建项目时,先提前判断一下该项目是否存在:

const targetDir = path.join(process.cwd(), name)
// 如果目标目录已存在,询问是覆盖还是合并
if (fs.existsSync(targetDir)) {
    // 清空控制台
    clearConsole()

    const { action } = await inquirer.prompt([
        {
            name: 'action',
            type: 'list',
            message: `Target directory ${chalk.cyan(targetDir)} already exists. Pick an action:`,
            choices: [
                { name: 'Overwrite', value: 'overwrite' },
                { name: 'Merge', value: 'merge' },
            ],
        },
    ])

    if (action === 'overwrite') {
        console.log(`\nRemoving ${chalk.cyan(targetDir)}...`)
        await fs.remove(targetDir)
    }
}

如果选择 overwrite,则进行移除 fs.remove(targetDir)

默认配置和手动模式

先在代码中提前把默认配置的代码写好:

exports.defaultPreset = {
    features: ['babel', 'linter'],
    historyMode: false,
    eslintConfig: 'airbnb',
    lintOn: ['save'],
}

这个配置默认使用 babeleslint

然后生成交互提示语时,先调用 getDefaultPrompts() 方法获取默认配置。

getDefaultPrompts() {
    const presets = this.getPresets()
    const presetChoices = Object.entries(presets).map(([name, preset]) => {
        let displayName = name

        return {
            name: `${displayName} (${preset.features})`,
            value: name,
        }
    })

    const presetPrompt = {
        name: 'preset',
        type: 'list',
        message: `Please pick a preset:`,
        choices: [
            // 默认配置
            ...presetChoices,
            // 这是手动模式提示语
            {
                name: 'Manually select features',
                value: '__manual__',
            },
        ],
    }

    const featurePrompt = {
        name: 'features',
        when: isManualMode,
        type: 'checkbox',
        message: 'Check the features needed for your project:',
        choices: [],
        pageSize: 10,
    }

    return {
        presetPrompt,
        featurePrompt,
    }
}

这样配置后,在用户选择功能前会先弹出这样的提示语:

包管理器

vue-cli 创建项目时,会生成一个 .vuerc 文件,里面会记录一些关于项目的配置信息。例如使用哪个包管理器、npm 源是否使用淘宝源等等。为了避免和 vue-cli 冲突,本脚手架生成的配置文件为 .mvcrc

这个 .mvcrc 文件保存在用户的 home 目录下(不同操作系统目录不同)。我的是 win10 操作系统,保存目录为 C:\Users\bin。获取用户的 home 目录可以通过以下代码获取:

const os = require('os')
os.homedir()

.mvcrc 文件还会保存用户创建项目的配置,这样当用户重新创建项目时,就可以直接选择以前创建过的配置,不用再一步步的选择项目功能。

在第一次创建项目时,.mvcrc 文件是不存在的。如果这时用户还安装了 yarn,脚手架就会提示用户要使用哪个包管理器:

// 读取 `.mvcrc` 文件
const savedOptions = loadOptions()
// 如果没有指定包管理器并且存在 yarn
if (!savedOptions.packageManager && hasYarn) {
    const packageManagerChoices = []

    if (hasYarn()) {
        packageManagerChoices.push({
            name: 'Use Yarn',
            value: 'yarn',
            short: 'Yarn',
        })
    }

    packageManagerChoices.push({
        name: 'Use NPM',
        value: 'npm',
        short: 'NPM',
    })

    otherPrompts.push({
        name: 'packageManager',
        type: 'list',
        message: 'Pick the package manager to use when installing dependencies:',
        choices: packageManagerChoices,
    })
}

当用户选择 yarn 后,下载依赖的命令就会变为 yarn;如果选择了 npm,下载命令则为 npm install

const PACKAGE_MANAGER_CONFIG = {
    npm: {
        install: ['install'],
    },
    yarn: {
        install: [],
    },
}

await executeCommand(
    this.bin, // 'yarn' or 'npm'
    [
        ...PACKAGE_MANAGER_CONFIG[this.bin][command],
        ...(args || []),
    ],
    this.context,
)

切换 npm 源

当用户选择了项目功能后,会先调用 shouldUseTaobao() 方法判断是否需要切换淘宝源:

const execa = require('execa')
const chalk = require('chalk')
const request = require('./request')
const { hasYarn } = require('./env')
const inquirer = require('inquirer')
const registries = require('./registries')
const { loadOptions, saveOptions } = require('./options')
  
async function ping(registry) {
    await request.get(`${registry}/vue-cli-version-marker/latest`)
    return registry
}
  
function removeSlash(url) {
    return url.replace(/\/$/, '')
}
  
let checked
let result
  
module.exports = async function shouldUseTaobao(command) {
    if (!command) {
        command = hasYarn() ? 'yarn' : 'npm'
    }
  
    // ensure this only gets called once.
    if (checked) return result
    checked = true
  
    // previously saved preference
    const saved = loadOptions().useTaobaoRegistry
    if (typeof saved === 'boolean') {
        return (result = saved)
    }
  
    const save = val => {
        result = val
        saveOptions({ useTaobaoRegistry: val })
        return val
    }
  
    let userCurrent
    try {
        userCurrent = (await execa(command, ['config', 'get', 'registry'])).stdout
    } catch (registryError) {
        try {
        // Yarn 2 uses `npmRegistryServer` instead of `registry`
            userCurrent = (await execa(command, ['config', 'get', 'npmRegistryServer'])).stdout
        } catch (npmRegistryServerError) {
            return save(false)
        }
    }
  
    const defaultRegistry = registries[command]
    if (removeSlash(userCurrent) !== removeSlash(defaultRegistry)) {
        // user has configured custom registry, respect that
        return save(false)
    }
  
    let faster
    try {
        faster = await Promise.race([
            ping(defaultRegistry),
            ping(registries.taobao),
        ])
    } catch (e) {
        return save(false)
    }
  
    if (faster !== registries.taobao) {
        // default is already faster
        return save(false)
    }
  
    if (process.env.VUE_CLI_API_MODE) {
        return save(true)
    }
  
    // ask and save preference
    const { useTaobaoRegistry } = await inquirer.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) {
        await execa(command, ['config', 'set', 'registry', registries.taobao])
    }

    return save(useTaobaoRegistry)
}

上面代码的逻辑为:

  1. 先判断默认配置文件 .mvcrc 是否有 useTaobaoRegistry 选项。如果有,直接将结果返回,无需判断。
  2. 向 npm 默认源和淘宝源各发一个 get 请求,通过 Promise.race() 来调用。这样更快的那个请求会先返回,从而知道是默认源还是淘宝源速度更快。
  3. 如果淘宝源速度更快,向用户提示是否切换到淘宝源。
  4. 如果用户选择淘宝源,则调用 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 是没有这段代码的:

// 注册淘宝源
if (useTaobaoRegistry) {
    await execa(command, ['config', 'set', 'registry', registries.taobao])
}

这是我自己加的。主要是我没有在 vue-cli 中找到显式注册淘宝源的代码,它只是从配置文件读取出是否使用淘宝源,或者将是否使用淘宝源这个选项写入配置文件。另外 npm 的配置文件 .npmrc 是可以更改默认源的,如果在 .npmrc 文件直接写入淘宝的镜像地址,那 npm 就会使用淘宝源下载依赖。但 npm 肯定不会去读取 .vuerc 的配置来决定是否使用淘宝源。

对于这一点我没搞明白,所以在用户选择了淘宝源之后,手动调用命令注册一遍。

将项目功能保存为默认配置

如果用户创建项目时选择手动模式,在选择完一系列功能后,会弹出下面的提示语:

询问用户是否将这次的项目选择保存为默认配置,如果用户选择是,则弹出下一个提示语:

让用户输入保存配置的名称。

这两句提示语相关的代码为:

const otherPrompts = [
    {
        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) => {
    const options = Object.assign(cloneDeep(exports.loadOptions()), toSave)
    for (const key in options) {
        if (!(key in exports.defaults)) {
            delete options[key]
        }
    }
    cachedOptions = options
    try {
        fs.writeFileSync(rcPath, JSON.stringify(options, null, 2))
        return true
    } catch (e) {
        error(
            `Error saving preferences: `
      + `make sure you have write access to ${rcPath}.\n`
      + `(${e.message})`,
        )
    }
}

exports.savePreset = (name, preset) => {
    const presets = cloneDeep(exports.loadOptions().presets || {})
    presets[name] = preset

    return exports.saveOptions({ presets })
}

以上代码直接将用户的配置保存到 .mvcrc 文件中。下面是我电脑上的 .mvcrc 的内容:

{
  "packageManager": "npm",
  "presets": {
    "test": {
      "features": [
        "babel",
        "linter"
      ],
      "eslintConfig": "airbnb",
      "lintOn": [
        "save"
      ]
    },
    "demo": {
      "features": [
        "babel",
        "linter"
      ],
      "eslintConfig": "airbnb",
      "lintOn": [
        "save"
      ]
    }
  },
  "useTaobaoRegistry": true
}

下次再创建项目时,脚手架就会先读取这个配置文件的内容,让用户决定是否使用已有的配置来创建项目。

至此,v2 版本的内容就介绍完了。

小结

由于 vue-cli 关于插件的源码我还没有看完,所以这篇文章只讲解前两个版本的源码。v3 版本等我看完 vue-cli 的源码再回来填坑,预计在 3 月初就可以完成。

如果你想了解更多关于前端工程化的文章,可以看一下我写的《带你入门前端工程》。 这里是全文目录:

  1. 技术选型:如何进行技术选型?
  2. 统一规范:如何制订规范并利用工具保证规范被严格执行?
  3. 前端组件化:什么是模块化、组件化?
  4. 测试:如何写单元测试和 E2E(端到端) 测试?
  5. 构建工具:构建工具有哪些?都有哪些功能和优势?
  6. 自动化部署:如何利用 Jenkins、Github Actions 自动化部署项目?
  7. 前端监控:讲解前端监控原理及如何利用 sentry 对项目实行监控。
  8. 性能优化(一):如何检测网站性能?有哪些实用的性能优化规则?
  9. 性能优化(二):如何检测网站性能?有哪些实用的性能优化规则?
  10. 重构:为什么做重构?重构有哪些手法?
  11. 微服务:微服务是什么?如何搭建微服务项目?
  12. Severless:Severless 是什么?如何使用 Severless?

参考资料

@basicButtons
Copy link

很优秀的,思路很清晰,不过我觉得v1部分 generator 和 render部分写的写的有点太简略了,哈哈哈哈。

@bhdgogogo
Copy link

你好,我想请问一下,脚手架怎么发布到npm上呢

@Dup4
Copy link

Dup4 commented Jul 24, 2023

你好,我想请问一下,脚手架怎么发布到npm上呢

npm publish

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants