来手写一个自己的脚手架吧|项目复盘

2,098 阅读6分钟

前言

这几天由于项目需求,搭建了好几次开发环境,每次都得重新配置一大堆东西,想着干脆自己搭一个脚手架好了。于是乎这两天自己简单的实现了一个脚手架,主要还是要能够拓展,写个文章来复盘一下。

搭建脚手架

接下来我们一步一步的实现脚手架的功能,感受一下整个过程。搭建用到的模块我都会附上链接,并简单介绍功能。

初始化项目

首先我们新建一个文件夹,并初始化

$ npm init -y

这样项目根目录就有了package.json文件了

创建命令文件

我们在根目录创建bin文件夹,并添加my-cli.js文件,内容如下:

#!/usr/bin/env node

// 把lib下的index.js作为入口
require('../lib/index')

同时,在package.json中添加以下内容:

    "bin": {
        "my-cli": "bin/my-cli.js"
    },

解释一下这两步,首先package.json中的bin内容,能够让我们在命令行调用my-cli xxx时去执行bin文件夹下面的my-cli.js文件。
另外#!/usr/bin/env node这句话就是去查找PATH中的node,也就是电脑中安装的nodejs来执行这个文件。 接下来我们执行下面的语句:

$ npm link

这句命令可以把我们的命令语句my-cli绑定到全局去,然后我们就可以直接在命令行调用一下了,我们试试直接调用

$ my-cli

会发现报错了,提示找不到..lib/index下的模块,因为我们还没创建。我们在根目录创建lib文件夹,并添加index.js文件,在里面输入console.log("Hello my-cli!")
然后我们再次执行my-cli,发现控制台输出了"Hello my-cli!"

处理解析指令

刚刚我们实现了my-cli指令,但是我们需要的是通过my-cli create xxx这种形式的指令来快速创建项目,那么我们就需要用到commander模块来帮助我们处理指令了。
先安装模块:

$ npm i commander

然后修改lib/index.js的内容如下:

// lib/index.js
const cmd = require('commander')

cmd
    .version(`${require('../package.json').version}`, '-v --version')
    .usage('<command> [options]');

cmd
    .command('create <name>')
    .description('Create new project')
    .action(async(name) => {
        //这里可以拿到传入的参数
        console.log('projectName:',name);
    });

cmd.parse(process.argv);

关于commander的使用方法可以去主页详细看看,这里我们声明了脚手架的版本,以及定义了create命令。<name>可以解析出传入的字符,并作为参数传入action回调中,因此我们现在调用my-cli create demo试试,结果就会输出projectName: demo
除了create指令,还可以定义许多别的自定义指令,需要自己去拓展了。

初始化项目

接下来我们需要在用户输入my-cli create xxx的时候,在当前目录下生成项目xxx,我们来实现一下这个功能。

为了不让index.js变得太臃肿,我们在lib下新建order文件夹来处理对应的命令,同时在order下创建create.js:

// lib/order/create.js
const { initProjectDir } = require("../utils/create");
module.exports = async function create(projectName) {
  //初始化项目目录
  initProjectDir(projectName);
};

同时把lib/index.js内容修改一下:

//lib/index.js
const cmd = require('commander');
const create = require('./order/create');
cmd
    .version(`${require('../package.json').version}`, '-v --version')
    .usage('<command> [options]');

cmd
    .command('create <name>')
    .description('Create new project')
    .action(async(name) => {
        //这里直接执行create命令
        create(name);
    });

cmd.parse(process.argv);

然后我们在lib/utils/create.js中实现initProjectDir

const { getProjectPath } = require("./common");
const { exec, cd } = require("shelljs");
const { existsSync } = require('fs');

function initProjectDir(projectName) {
  // 判断文件是否已经存在
  const file = getProjectPath(projectName);
  // // 验证文件是否已经存在,存在则退出
  if (existsSync(file)) {
    console.log(`${file} 已经存在`);
    process.exit(1);
  }
  exec(`mkdir ${projectName}`);
  cd(projectName);
}

module.exports = {
  initProjectDir,
};

这里用到了shelljs模块,用于在命令行执行语句,安装一下:

$ npm i shelljs

还有一些共用方法,我们在lib/utils/common.js中添加:

const { resolve } = require('path')

function getProjectPath(projectName) {
    return resolve(process.cwd(), projectName);
}

module.exports = {
    getProjectPath,
}

然后当我们执行my-cli create demo时,就会在当前目录创建出demo文件夹

处理用户交互

初始化出了项目的文件夹,接下来就是安装用户需要的功能,我们使用Inquirer来处理用户的交互,先安装一下:

$ npm i inquirer

然后我们在lib/utils/create.js中添加需要的功能(这里不要直接复制粘贴,是相对于之前添加的内容):

const { prompt } = require('inquirer')
async function selectFeature() {
    const { feature } = await prompt([{
        name: 'feature',
        type: 'checkbox',
        message: 'Check the features needed for your project',
        choices: [
            { name: 'vite', value: 'vite', checked: true },
            { name: 'typescript', value: 'typescript' },
            { name: 'babel', value: 'babel' },
        ],
    }, ]);

    return feature;
}

module.exports = {
    selectFeature,
}

typecheckbox表示让用户多选,choices就是用户可选的模块:

image.png
选择的结果会存入feature中,可以用于下一步按照模块作为基准。

安装选中的模块

用户选好了需要的功能,那么接下来我们就需要把选中的功能给他安装到项目里去,这一块我们分两步走

添加包依赖

这一步需要把功能用到的npm模块依赖添加到package.json里面去。

我们在lib下新建一个文件夹feature,里面的文件负责处理对应的模块。以babel为例,我们在feature下新建babel.js,添加下面的内容:

// lib/feature/babel.js

const { extendPackage} = require('../utils/common');
module.exports = function(packageJson) {
    mergePackage(packageJson);
}

function mergePackage(packageJson) {
    const babelConfig = {
        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',
        },
    }

    extendPackage(babelConfig, packageJson);
}

这里预先把需要的babel依赖版本等写好,然后通过extendPackage函数合并到总的package.json里面去。
我们在lib/utils/common.js中添加extendPackage函数:

function extendPackage(minor, main) {
    for (let key in minor) {
        if (main[key] === undefined) {
            main[key] = minor[key];
        } else {
            if (Object.prototype.toString.call(minor[key]) === '[object Object]') {
                extendPackage(minor[key], main[key]);
            } else {
                main[key] = minor[key];
            }
        }
    }
};
module.exports = {
    extendPackage,
}

这样package.json文件就被改写掉了,至于什么时候传入packageJson我们等下再介绍。

添加配置文件

类似babel的功能,用户可能会用babel.config.js来配置文件,那么我们怎么在用户选择了babel功能后,同时给他创建出一个基本配置好了的配置文件呢?
答案就是用模板文件,我们可以预先创建好默认的babel.config.js文件,在用户选择了babel功能之后,把默认的配置文件复制一份到新创建的目录下面,方便快捷。
我们在lib下创建template文件夹,下面对应的每一个文件夹都是一个功能的模块。我们创建babel文件夹,然后添加babel.config.js文件:

module.exports = {
    presets: [
        [
            '@babel/preset-env',
            {
                loose: true,
                targets: { node: 'current' }
            }
        ],
    ],
};

接下来我们要做的就是,把模板文件按照目录结构复制到新的项目里去,我们在lib/utils/common.js添加赋值模板文件的函数:

const { resolve } = require('path')
const { writeFile, readdir, stat, readFileSync, mkdirSync } = require('fs');

async function copyTemplate(from, to) {
    stat(from, (err, stat) => {
        //如果是目录,则遍历复制
        if (stat.isDirectory()) {
            readdir(from, (err, paths) => {
                paths.forEach(path => {
                    //如果是文件夹,则创建
                    if (!/\./.test(path)) {
                        mkdirSync(to + "\\" + path)
                    }
                    copyTemplate(from + "\\" + path, to + "\\" + path)
                })
            })
        } else {
            //否则直接复制文件
            writeFile(to, readFileSync(from), () => {});
        }
    })
}

function generateFiles(tempName) {
    const from = resolve(__dirname, `../template/${tempName}`);
    const to = process.cwd();
    copyTemplate(from, to);
}


module.exports = {
    generateFiles
}

到这里安装模块的准备工作就完成了,我们回到lib/utils/create.js,添加installFeature函数:

function installFeature(feature, projectName) {
    //根据需要的feature,到文件名对应的路径下加载对应的功能模块
    const featureArr = feature.map(name => require(`../feature/${name}`));
    
    //设置默认的package.json内容
    const packageJson = {
        name: projectName,
        version: '1.0.0',
        dependencies: {},
        devDependencies: {},
    }
    
    //调用对应功能的创建方法
    featureArr.forEach(item => {
        item(packageJson)
    })

    return packageJson;
}

module.exports = {
  installFeature,
};

这里做了一个遍历用户选中的功能,并执行对应功能模块的创建方法的操作,而合并配置文件和复制模板文件的操作在每个功能模块自己的创建方法里:

// lib/feature/babel.js
const { extendPackage, generateFiles } = require('../utils/common');
module.exports = function(packageJson) {
    mergePackage(packageJson);
    generateFiles('babel');
}


function mergePackage(packageJson) {
    const babelConfig = {
        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',
        },
    }

    extendPackage(babelConfig, packageJson);
}

然后我们回到lib/order/create.js,执行一下installFeature

const { initProjectDir, selectFeature, installFeature,  } = require("../utils/create");

// create 命令
module.exports = async function create(projectName) {
    // 初始化项目目录
    initProjectDir(projectName);

    // 选择需要的功能
    const feature = await selectFeature();

    //安装对应的功能
    const package = installFeature(feature, projectName);
}

生成package.json

刚刚通过installFeature,我们拿到了合并完所有功能的最终的package.json配置内容,我们写个函数写入到生成的项目中:

// lib\utils\create.js
function initPackage(package) {
    writeFileSync(process.cwd() + "/package.json", JSON.stringify(package, null, 4));
}

module.exports = {
    initPackage,
}

// lib\order\create.js
const { initProjectDir, selectFeature, installFeature, initPackage } = require("../utils/create");
module.exports = async function create(projectName) {
    // 初始化项目目录
    initProjectDir(projectName);

    // 选择需要的功能
    const feature = await selectFeature();

    //安装对应的功能
    const package = installFeature(feature, projectName);

    // 写入package
    initPackage(package);
}

这样就生成了package.json文件

安装依赖

到这一步,基本上的工作都做完了,我们只需要在新项目中执行npm i把包全都装上就好了:

// lib\utils\create.js
const { existsSync, writeFileSync } = require('fs');
function installModule() {
    exec('npm i')
}
module.exports = {
    installModule,
}

// lib\order\create.js
const { initProjectDir, selectFeature, installFeature, initPackage, installModule } = require("../utils/create");
module.exports = async function create(projectName) {
    // 初始化项目目录
    initProjectDir(projectName);

    // 选择需要的功能
    const feature = await selectFeature();

    //安装对应的功能
    const package = installFeature(feature, projectName);

    // 写入package
    initPackage(package);

    //进入目录并安装modules
    installModule();
}

拓展

如果需要添加功能的话,只需要在lib/feature下面添加对应的功能文件,并在lib/template下添加模板文件即可。
如果需要添加命令,可以在lib/order下添加命令文件即可。 大功告成!

总结

本篇主要讲述了如何搭建一个可拓展的脚手架,并通过模板文件的方式来生成需要的功能。由于本人也是第一次搭建脚手架,存在很多不足的地方,比如模板文件其实还可以设置成动态模板的形式,根据用户的输入改变模板的内容。但是总的来说这些都可以在这个脚手架上进行拓展和改进,如果有什么更好的建议也欢迎大家提出,一定虚心改正!

写在最后

  1. 以上操作源码已放在github.com/AaronY666/m… ,并按照步骤有commit记录
  2. 很感谢你能看到这里,不妨点个赞支持一下,万分感激~!
  3. 以后会更新更多文章和知识点,感兴趣的话可以关注一波~

参考文章:

本文正在参与「掘金 2021 春招闯关活动」, 点击查看活动详情