Skip to content

574495412/chrise-cli

Repository files navigation

chrise-cli 脚手架

安装

$ npm i @chrise/cli -g
$ chrise create test

前言

掘金文章

后面去看了@vue/cli的源码,不得不说,确实很正式很规范化。参考了@vue/cli 的部分功能,对脚手架进行修改。

效果栗子

image

项目结构

项目chrise-react的结构分成了四个部分:

  • @chrise/cli 脚手架命令行内容,通过命令去初始化项目等等操作。
  • @chrise/scripts 项目编译运行打包内容,暂未接入部署等流程。
  • @chrise/template 模板文件。
  • @chrise/plugin-xxx 项目封装的插件,例如@chrise/plugin-typescript等等。

cli

之前是直接通过inquirer的一些交互命令,获取信息后,通过download-git-repo去对应github拉取模板文件,这样虽然比较简单,但是多个模板的话,维护就很难受了。

而在@vue/cli里,是通过多个插件组合成一个整体模板,在很多脚手架根目录下,都有一个xxx.config.js暴露出来。然后在运行node命令时,去读取配置文件,根据配置文件的内容去进行对应操作,例如:使用webpack-chain动态修改config,最后再调用toConfig去生成新的webpack配置内容。

插件判断,生成 pkg

一个基本的package.json模板,除了常规不变的versionprivatelicense等等,像namescriptsdependenciesdevDependencies需要我们去手动添加进去。

name就使用脚手架初始化传入的参数,而scripts则是在成功引入@chrise/scripts后,使用其运行命令。

像一些必备的,例如reactreact-dom,我们可以直接放到dependencies里,而devDependencies一般是初始化时,用户手动选择的plugins

借助inquirer去罗列插件,让用户选择需要引入哪些插件。

const chrise_PLUGIN_CHECK = [
  {
    name: 'Typescript',
    value: ['tsx', '@chrise/plugin-typescript'],
  },
  {
    name: 'Less',
    value: ['less', '@chrise/plugin-less'],
  },
];

const { plugins } = await inquirer.prompt([
  {
    type: 'checkbox',
    name: 'plugins',
    message: 'Do you need these plugins',
    choices: chrise_PLUGIN_CHECK, // 一个结构,自己觉得怎么处理方便,怎么来
  },
]);

那么根据上面的code就可以获取到,用户需要哪些plugin了,那么可以将这些plugin放入到jsondevDependencies里。

获取依赖最新版本

上述无论是react还是plugin,都需要一个版本号,我这里是采用命令行去获取最新版本,然后作为其value值。如果直接遍历运行execSync的话,会阻塞住,oraloading也要卡着不动,于是选择promise去运行,通过exec回调来结束promise

const forEachSetV = (list, obj, key) => {
  const promises = [];
  const manager = hasCnpm() ? 'cnpm' : 'npm'; // 判断选择cnpm还是npm

  list.forEach((item) => {
    if (typeof item === 'object') {
      return forEachSetV(item, obj, key);
    }

    const newPromise = new Promise((res) => {
      exec(`${manager} view ${item} version`, (err, stdout, stderr) => {
        obj[key][item] = stdout.slice(0, stdout.length - 1);
        res(0);
      });
    });

    promises.push(newPromise);
  });

  return promises;
};

const promise = [
  ...forEachSetV(depe, pkg, 'dependencies'),
  ...forEachSetV(devD, pkg, 'devDependencies'),
];
await Promise.all(promise);

那么就获取到版本号后,再将其他数据一同填入到json中,将其作为package.json的值,在新项目目录下,新建它。

const fs = require('fs-extra'); // fs-extra是系统fs模块的扩展
const path = require('path');

module.exports = (dir, files) => {
  Object.keys(files).forEach((name) => {
    const pathName = path.join(dir, name);
    fs.ensureDirSync(path.dirname(pathName)); // 如果没有文件夹则新建文件夹
    fs.writeFileSync(pathName, files[name]); // 新建文件
  });
};

writeFileTree(targetDir, {
  'package.json': JSON.stringify(pkg, null, 2),
});

选择包管理工具

因为npm的速度不甚理想,可以将其作为兜底处理。先判断当前环境中,是否有yarncnpm等等,然后优先选择前者,若都没有,则再使用npm进行操作。

const PM_CONFIG = {
  npm: {
    install: ['install', '--loglevel', 'error'], // 打印error信息
    remove: ['uninstall', '--loglevel', 'error'],
  },
  yarn: {
    install: [],
    remove: ['remove'],
  },
};
PM_CONFIG.cnpm = PM_CONFIG.npm;

module.exports = class PackageManager {
  constructor({ pkgName }) {
    this.pkgName = pkgName;

    if (hasYarn()) {
      this.bin = 'yarn';
    } else if (hasCnpm()) {
      this.bin = 'cnpm';
    } else {
      this.bin = 'npm';
    }
  }

  // 封装了下运行命令函数
  runCommand(command, args = []) {
    const _commands = [this.bin, ...PM_CONFIG[this.bin][command], ...args];
    execSync(_commands.join(' '), { stdio: [0, 1, 2] });
  }

  install() {
    try {
      this.runCommand('install', ['--offline']); // offline指先去拉取缓存区里的,如果没有则去服务器拉
    } catch (e) {
      this.runCommand('install'); // 报错兜底
    }
  }

  git() {
    try {
      execSync('git init');
      return true;
    } catch (e) {
      return false;
    }
  }
};

而判断yarncnpm环境中是否存在,可以通过判断version等等方法,去看是否能够成功执行,若成功执行,则说明环境中存在,反之则否。

const judgeEnv = (name) => {
  const envKey = `_has${name[0].toUpperCase()}${name.slice(1)}`; // 保存下结果

  if (_env[envKey] !== null) {
    return _env[envKey];
  }

  try {
    execSync(`${name} --version`, { stdio: 'ignore' }); // 不打印信息

    return (_env[envKey] = true);
  } catch (e) {
    return (_env[envKey] = false);
  }
};

const hasYarn = judgeEnv.bind(this, 'yarn');

const hasCnpm = judgeEnv.bind(this, 'cnpm');

然后通过install方法去安装依赖,再将一些参数传递给@chrise/template去把一些基本模板复制过去。

scripts

因为是多页面项目,scripts里主要做了以下几件事情:

  • 通过glob去匹配入口,然后将其作为entry动态传入,并动态传入多个html-webpack-pluginplugins
  • 通过读取项目根目录下的chrise.config.js文件,来动态修改webpack配置内容并调用对应的插件。
  • 最后生成最终的webpack配置文件,传入给webpack去进行编译运行打包等等操作。

匹配入口

匹配入口主要使用glob去匹配,只有满足匹配要求,才作为入口。然后通过匹配到的信息,去生成对应的entry内容,和plugin内容,传递给webpack配置文件。

const SRC = './src/**/index.?(js|jsx|ts|tsx)';
/**
 * get webpack entry
 */
const getEntries = () => {
  if (entries) return entries;
  entries = {};

  const pages = glob.sync(SRC);
  pages.forEach((page) => {
    // 遍历传entry
    const dirname = path.dirname(page);
    const entry = path.basename(dirname);
    entries[entry] = page;
  });
  return entries;
};

/**
 * get pages info
 * @param {Boolean} isProd
 */
const getPages = (isProd) => {
  const plugins = [];
  let entries = getEntries();

  Object.keys(entries).map((dirname) => {
    // 遍历传plugin
    plugins.push(
      new HtmlWebpackPlugin({
        chunks: isProd ? ['libs', dirname] : [dirname],
        filename: `./${dirname}/index.html`,
        template: path.join(__dirname, './template/index.html'),
      })
    );
  });

  return plugins;
};

链式配置 config

链式配置推荐使用webpack-chain@vue/cli也是使用它。因为我们原本就有一些基本配置内容,可以通过config.merge将我们已有的配置对象合并到配置实例中。

但是不支持直接转化,需要我们对某些配置内容,进行手动去转化,例如:module。而plugins不支持已经newplugin,我这边的处理是跳过对plugin的合并,最后再使用webpack-mergeconfig.toConfig()plugins再合并成最终的配置对象。

const Config = require('webpack-chain');
const chriseConfig = require(`${process.cwd()}/chrise.config.js`); // 读取根目录的配置文件
const { setBaseConfig } = require('./util/merge'); // 将已有的配置文件对象合并到配置实例
const BASE = require('./config/webpack.base'); // 配置对象base
const DEVE = merge(BASE, require('./config/webpack.deve')); // 配置对象 deve
const PROD = merge(BASE, require('./config/webpack.prod')); // 配置对象 prod

const config = new Config();

// 我这边就只是对plugin做一下处理,可以做其他很多事情,这里只是举个例子
const handlechriseConfig = ({ plugins } = {}) => {
  // to do sth.
  if (plugins) {
    plugins.forEach((plugin) => {
      require(plugin[0])(config, plugin[1]);
    });
  }
};

const getConfig = (isDeve) => {
  config.clear(); // 清除配置

  setBaseConfig(isDeve ? DEVE : PROD, config);
  handlechriseConfig(chriseConfig);

  return merge(config.toConfig(), {
    plugins: isDeve ? DEVE.plugins : PROD.plugins,
  }); // 最后再合并
};

编译运行

在获取到webpack config后,那么可以根据是dev命令还是build命令,去调用对应的函数,进行编译运行打包等等操作了。(同理,根据program.command

// dev 运行
const webpack = require('webpack');
const WebpackDevServer = require('webpack-dev-server');
const { getDeveConfig } = require('./config');

module.exports = () => {
  const DEVE_CONFIG = getDeveConfig();
  const { devServer } = DEVE_CONFIG;
  const compiler = webpack(DEVE_CONFIG);
  const server = new WebpackDevServer(compiler, { ...devServer });
  server.listen(devServer.port);
};
// build
const webpack = require('webpack');
const { getProdConfig } = require('./config');
const logSymbols = require('log-symbols');

module.exports = () => {
  const PROD_CONFIG = getProdConfig();
  const compiler = webpack(PROD_CONFIG);

  compiler.run((err, stats) => {
    if (err) {
      // 回调中接收错误信息。
      console.error(err);
    } else {
      console.log(logSymbols.success, '打包成功!');
    }
  });
};

template

template主要就是通过传入的参数,来判断是否要copy对应的文件,同时根据options来去修改对应文件内容和后缀。代码过于无趣,就不贴了。

plugin-xx

plugin的话,可以做的事情比较多,我这边目前就只是来链式修改webpack配置信息。这只是其中一种功能,还有很多例如:自己写一个webpack plugin / loader去传入,去做一些其他事情。

// example
module.exports = (config) => {
  ['.tsx', '.ts'].forEach((item) => config.resolve.extensions.add(item));

  config.module
    .rule('js')
    .test(/\.(js|ts|tsx|jsx)$/)
    .use('babel-loader')
    .tap((options) => {
      options.presets.push('@babel/preset-typescript');
      return options;
    });
};