手写webpack脚手架命令行工具

2,729 阅读5分钟

日常吐槽

本来想搭建一个webpack脚手架的,于是在搭建的过程中不断地搜集相关资料。可最终的结果是,webpack脚手架没有搭建成,却写出个 CLI 小工具。其实,这也并不是没有原因的。现在流行的框架都推出了自己的脚手架工具,比如,Vue CLI,Create React App 等。脚手架和CLI往往如影随形,这也导致了两者在概念上的混淆。标题为什么这么拗口,其实是为了区分这两个概念。

我有一个想法

既然被带跑偏了,就只能在跑偏的路上越跑越远吧。

命令行界面(英语:Command-Line Interface,缩写:CLI)是在图形用户界面得到普及之前使用最为广泛的用户界面,它通常不支持鼠标,用户通过键盘输入指令,计算机接收到指令后,予以执行。也有人称之为字符用户界面(character user interface, CUI) ———— 维基百科

使用过 Vue CLI 的同学应该都知道,我们只需要在终端敲几个的命令就可以搭建一个 Vue 的脚手架。如果不使用 CLI 的话,每次创建项目时,都需要配置文件(比如webpack配置文件)、设计结构、技术栈选型等。如果每次从零开始去搭建项目就会很麻烦,所以我们可以把相同的东西抽离成脚手架。以后创建项目时,就可以直接把脚手架复制过来,并以此为基础搭建项目。

回过头来再看看我们手动搭建项目的过程,从每次从零开始搭建项目到脚手架的复用,这中间有了很大的进步。可即使是复制黏贴,我们依然觉得很麻烦,如果用命令行的方式来取代图形操作,我们就可以更懒一些了。

回到主题,我本来打算写的webpack脚手架是基于这样的一个想法。➡️ 现在大部分的前端工程,webpack作为打包工具已经成了标配了。而 webpack 的配置是大同小异的,完全可以剥离出一个通用的webpack配置,然后针对个别配置进行修改。本次希望最终实现一个基于webpack适用于不同前端模板(React、Vue、ES+)的脚手架。

现在脚手架有了,如何自动化去搭建一个项目呢?

  1. 复制或下载脚手架模板。(为了更灵活,上传到GitHub,或发布npm中)。

  2. 根据不同需求,在脚手架模板基础上重新配置webpack、package文件。

  3. 安装依赖。

以下代码可见GitHub

CLI 中的预备工作

首先了解一下 #!。文件开头要加上#! /usr/bin/env node

在计算领域中,Shebang(也称为 Hashbang )是一个由井号和叹号构成的字符序列 #! ,其出现在文本文件的第一行的前两个字符。 在文件中存在 Shebang 的情况下,类 Unix 操作系统的程序加载器会分析 Shebang 后的内容,将这些内容作为解释器指令,并调用该指令,并将载有 Shebang 的文件路径作为该解释器的参数。 ———— 维基百科

使用 #!/usr/bin/env 脚本解释器名称 是一种常见的在不同平台上都能正确找到解释器的办法。 ———— 维基百科

然后看看都用到了哪些东西(部分)。

npm install commander chalk fs-extra shelljs inquirer ora ejs --save
#! /usr/bin/env node

// multi-spa.js
const program = require('commander');  // 解析命令;
const chalk = require('chalk');  // 命令行界面输出美颜
const fs = require('fs-extra');  // fs的拓展;
const shell = require('shelljs');  // 重新包装了 child_process;
const inquirer = require('inquirer');  // 交互式问答;
const ora = require('ora');  // 输出样式美化;
const ejs = require('ejs');  // 模版引擎;
const path = require('path');
const currentPath = process.cwd();
let answersConfig = null;

命令的解析

类似与 Vue 的 vue init,我们也希望自己的 CLI 也能拥有类似的功能。

// package.json
  "bin": {
    "multi-spa-webpack": "./bin/multi-spa.js"
  },

这样,我们就有了multi-spa-webpack的命令。如果我们想要全局使用,还需要执行下面命令。

npm link

接下来就要初始化multi-spa-webpack相关的命令了。

// multi-spa.js
program
  .command('init <项目路径> [选项]')
  .description('指令说明:初始化项目')
  .action(async (appName) => {
    try {
      answersConfig = await getAnswers(appName);
      let targetDir = path.resolve(currentPath, appName || '.');
      if (fs.pathExistsSync(targetDir)) {
        if (program.force) {
          GenarateProject(appName);  // 创建项目;
        }
        ora(chalk.red(`!当前目录下,${appName}已存在,请修改名称后重试`)).fail();
        process.exit(1);
      };
      GenarateProject(appName);  // 创建项目;
    } catch (error) {
      ora(chalk.red(`项目创建失败:${error}`)).fail();
      process.exit(1);
    }
  });
program
  .arguments('<command>')
  .action((cmd) => {
    console.log();
    console.log(chalk.red(`!命令未能解析 <${chalk.green(cmd)}>`));
    console.log();
    program.outputHelp();
    console.log();
  });
program.parse(process.argv);
if (program.args.length === 0) {
  console.log();
  console.log(chalk.red('!输入的命令有误'));
  console.log();
  chalk.cyan(program.help());
}

复制或下载模板

在执行multi-spa-webpack init spa-project后,就需要拷贝一份脚手架到本地了。至于脚手架从哪里来,可以放在 github 上(类似 Vue CLI)或 放在 CLI 对应的目录下(类似create-react-app)。

本文是采用的是从 github 获取脚手架模板的。但是常规的方式,只能下载整个项目,而对于不需要的文件夹或文件,也会同时下载,下载后,只能在本地中删除无关文件了。我这里是从源头上剔除无关文件的下载,这个方法可能会有一些局限性吧(sparse-checkout)。不过两者最终的目的是一样的。

// multi-spa.js
function DownTemplate(projectDir) {
  const remote = 'https://github.com/yexiaochen/multi-spa-webpack-cli.git';
  const { template } = answersConfig;
  let downTemplateSpinner = ora(chalk.cyan('模板下载中...')).start();
  return new Promise((resolve, reject) => {
    shell.exec(`
      mkdir ${projectDir}
      cd ${projectDir}
      git init
      git remote add -f origin ${remote}
      git config core.sparsecheckout true
      echo "template/common" >> .git/info/sparse-checkout
      echo "template/config" >> .git/info/sparse-checkout
      echo "template/services" >> .git/info/sparse-checkout
      echo "template/${template}" >> .git/info/sparse-checkout
      echo ".gitignore" >> .git/info/sparse-checkout
      echo "package.json" >> .git/info/sparse-checkout
      git pull origin master
      rm -rf .git
      mv template/* ./
      rm -rf template
      `, (error) => {
        if (error) {
          downTemplateSpinner.stop()
          ora(chalk.red(`模板下载失败:${error}`)).fail()
          reject()
        }
        downTemplateSpinner.stop();
        ora(chalk.cyan('模板下载成功')).succeed();
        resolve();
      })
  })
}

重新生成配置文件

像 webpack、package 等配置文件,也都是包含在脚手架里的,不过这些配置还不能直接拿来用。我们还需要通过交互式问答,来针对性得在现有的基础上重新生成配置文件。

// multi-spa.js
function getAnswers(appName) {
  const options = [
    {
      type: 'input',
      name: 'name',
      message: '项目名称',
      default: appName,
    },
    {
      type: 'input',
      name: 'description',
      message: '项目描述',
      default: '单页面应用',
    },
    {
      type: 'confirm',
      name: 'eslint',
      message: '是否启用 eslint+pretty',
      default: true
    },
    {
      name: 'cssPreprocessor',
      type: 'list',
      message: 'CSS 预处理器',
      choices: [
        "less",
        "sass",
        "none",
      ]
    },
    {
      name: 'template',
      type: 'list',
      message: '选取模板',
      choices: [
        "react",
        "vue",
        "es"
      ]
    },
  ];
  return inquirer.prompt(options);
}

在获得特定的需求后,还要把这些数据注入到配置文件中。就是通过模板引擎把数据塞到模板里。这里使用的是 ejs 模版引擎。

<!--webpack.common.ejs-->
<%= answers.cssPreprocessor == 'none'
    ? /\.css$/ : (answers.cssPreprocessor == 'less' ? /\.less$/ : /\.scss$/) %>

<%= answers.cssPreprocessor == 'none'
    ? '' : (answers.cssPreprocessor == 'less' ? 'less-loader' : 'sass-loader') %>
// multi-spa.js
async function GenarateWebpackConfig(targetDir) {
  try {
    const webpackConfigPath = path.resolve(`${currentPath}/${targetDir}/config`, 'webpack.common.ejs');
    const webpackConfigTargetPath = path.resolve(`${currentPath}/${targetDir}/config`, 'webpack.common.js');
    const webpackConfigSpinner = ora(chalk.cyan(`配置 webpack 文件...`)).start();
    let webpackConfig = await fs.readFile(webpackConfigPath, 'utf8');
    let generatedWebpackConfig = ejs.render(webpackConfig, { answers: answersConfig });
    await Promise.all([
      fs.writeFile(webpackConfigTargetPath, generatedWebpackConfig),
      fs.remove(webpackConfigPath)
    ])
    webpackConfigSpinner.stop();
    ora(chalk.cyan(`配置 webpack 完成`)).succeed();
  } catch (error) {
    ora(chalk.red(`配置文件失败:${error}`)).fail();
    process.exit(1);
  }
}
async function GenaratePackageJson(projectDir) {
  try {
    const { name, description, cssPreprocessor } = answersConfig;
    const packageJsonPath = path.resolve(`${currentPath}/${projectDir}`, 'package.json');
    const packageJsonSpinner = ora(chalk.cyan('配置 package.json 文件...')).start();
    let package = await fs.readJson(packageJsonPath);
    package.name = name;
    package.description = description;
    if (cssPreprocessor == 'less') {
      package.devDependencies = {
        ...package.devDependencies,
        "less-loader": "^5.0.0"
      }
    }
    if (cssPreprocessor == 'sass') {
      package.devDependencies = {
        ...package.devDependencies,
        "node-sass": "^4.12.0",
        "sass-loader": "^7.1.0"
      }
    }
    await fs.writeJson(packageJsonPath, package, { spaces: '\t' });
    packageJsonSpinner.stop();
    ora(chalk.cyan('package.json 配置完成')).succeed();
  } catch (error) {
    if (error) {
      ora(chalk.red(`配置文件失败:${error}`)).fail();
      process.exit(1);
    };
  }
}

安装依赖

其实配置文件生成后,CLI 就快接近尾声了。剩下就是安装依赖。

// multi-spa.js
function InstallDependencies(targetDir) {
  const installDependenciesSpinner = ora(chalk.cyan(`安装依赖中...`)).start();
  return new Promise((resolve, reject) => {
    shell.exec(`
    cd ${targetDir}
    npm i
    `, (error) => {
        if (error) {
          installDependenciesSpinner.stop()
          ora(chalk.red(`依赖安装失败:${error}`)).fail()
          reject()
        }
        installDependenciesSpinner.stop();
        ora(chalk.cyan('依赖安装完成')).succeed();
        resolve();
      })
  })
}

小结

一个粗糙的CLI,就这么完成了。把以上几个方法包装一下,就是本次 CLI 的全部内容了。

  1. 拷贝脚手架。2. 重新生成配置文件。3安装依赖。
async function GenarateProject(targetDir) {
  await DownTemplate(targetDir);
  await Promise.all([GenaratePackageJson(targetDir).then(() => {
    return InstallDependencies(targetDir);
  }),
  GenarateWebpackConfig(targetDir)
  ]);
  ora(chalk.cyan('项目创建成功!')).succeed();
}

如果想要发布,需要登陆npm ,npm publish