如何写一个自己的脚手架 - 一键初始化项目
如何写一个自己的脚手架 - 一键初始化项目
介绍
脚手架的作用:为减少重复性工作而做的重复性工作
即为了开发中的:编译 es6,js 模块化,压缩代码,热更新等功能,我们使用[代码]webpack[代码]等打包工具,但是又带来了新的问题:初始化工程的麻烦,复杂的[代码]webpack[代码]配置,以及各种配置文件,所以就有了一键生成项目,0 配置开发的脚手架
本文项目代码地址
本文以我司的脚手架工具 简化之后为基础
本系列分 3 篇,详细介绍如何实现一个脚手架:
一键初始化项目
0 配置开发环境与打包
一键上传服务器
首先说一下个人的开发习惯
在写功能前我会先把调用方式写出了,然后一步一步的从使用者的角度写,现将基础功能写好后,慢慢完善
例如一键初始化项目功能
我期望的就是 在命令行执行输入 [代码]my-cli create text-project[代码],回车后直接创建项目并生成模板,还会把依赖都下载好
我们下面就从命令行开始入手
创建项目 [代码]my-cli[代码],执行 [代码]npm init -y[代码]快速初始化
bin
[代码]my-cli[代码]:
在 [代码]package.json[代码] 中加入:
[代码]{
"bin": {
"my-cli": "bin.js"
}
}
[代码]
[代码]bin.js[代码]:
[代码]#!/usr/bin/env node
console.log(process.argv);
[代码]
[代码]#!/usr/bin/env node[代码],这一行是必须加的,就是让系统动态的去[代码]PATH[代码]目录中查找[代码]node[代码]来执行你的脚本文件。
命令行执行 [代码]npm link[代码] ,创建软链接至全局,这样我们就可以全局使用[代码]my-cli[代码]命令了,在开发 [代码]npm[代码] 包的前期都会使用[代码]link[代码]方式在其他项目中测试来开发,后期再发布到[代码]npm[代码]上
命令行执行 [代码]my-cli 1 2 3[代码]
输出:[代码][ '/usr/local/bin/node', '/usr/local/bin/my-cli', '1', '2', '3' ][代码]
这样我们就可以获取到用户的输入参数
例如[代码]my-cli create test-project[代码]
我们就可以通过数组第 [2] 位判断命令类型[代码]create[代码],通过第 [3] 位拿到项目名称[代码]test-project[代码]
commander
[代码]node[代码]的命令行解析最常用的就是[代码]commander[代码]库,来简化复杂[代码]cli[代码]参数操作
(我们现在的参数简单可以不使用[代码]commander[代码],直接用[代码]process.argv[3][代码]获取名称,但是为了之后会复杂的命令行,这里也先使用[代码]commander[代码])
[代码]#!/usr/bin/env node
const program = require("commander");
const version = require("./package.json").version;
program.version(version, "-v, --version");
program
.command("create <app-name>")
.description("使用 my-cli 创建一个新的项目")
.option("-d --dir <dir>", "创建目录")
.action((name, command) => {
const create = require("./create/index");
create(name, command);
});
program.parse(process.argv);
[代码]
[代码]commander[代码] 解析完成后会触发[代码]action[代码]回调方法
命令行执行:[代码]my-cli -v[代码]
输出:[代码]1.0.0[代码]
命令行执行: [代码]my-cli create test-project[代码]
输出:[代码]test-project[代码]
创建项目
拿到了用户传入的名称,就可以用这么名字创建项目
我们的代码尽量保持[代码]bin.js[代码]整洁,不将接下来的代码写在[代码]bin.js[代码]里,创建[代码]create[代码]文件夹,创建[代码]index.js[代码]文件
[代码]create/index.js[代码]中:
[代码]const path = require("path");
const mkdirp = require("mkdirp");
module.exports = function(name) {
mkdirp(path.join(process.cwd(), name), function(err) {
if (err) console.error("创建失败");
else console.log("创建成功");
});
};
[代码]
[代码]process.cwd()[代码]获取工作区目录,和用户传入项目名称拼接起来
(创建文件夹我们使用[代码]mkdirp[代码]包,可以避免我们一级一级的创建目录)
修改[代码]bin.js[代码]的[代码]action[代码]方法:
[代码]// bin.js
.action(name => {
const create = require("./create")
create(name)
});
[代码]
命令行执行: [代码]my-cli create test-project[代码]
输出:[代码]创建成功[代码]
并在命令行所在目录创建了一个[代码]test-project[代码]文件夹
模板
首先需要先列出我们的模板包含哪些文件
一个最基础版的[代码]vue[代码]项目模板:
[代码]|- src
|- main.js
|- App.vue
|- components
|- HelloWorld.vue
|- index.html
|- package.json
[代码]
这些文件就不一一介绍了
我们需要的就是生成这些文件,并写入到目录中去
模板的写法后很多种,下面是我的写法:
模板目录:
[代码]|- generator
|- index-html.js
|- package-json.js
|- main.js
|- App-vue.js
|- HelloWorld-vue.js
[代码]
[代码]generator/index-html.js[代码] 模板示例:
[代码]module.exports = function(name) {
const template = `
{
"name": "${name}",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {},
"devDependencies": {
},
"author": "",
"license": "ISC",
"dependencies": {
"vue": "^2.6.10"
}
}
`;
return { template, dir: "", name: "package.json" };
};
[代码]
[代码]dir[代码]就是目录,例如[代码]main.js[代码]的[代码]dir[代码]就是[代码]src[代码]
[代码]create/index.js[代码]在[代码]mkdirp[代码]中新增:
[代码]const path = require("path");
const mkdirp = require("mkdirp");
const fs = require("fs");
module.exports = function(name) {
const projectDir = path.join(process.cwd(), name);
mkdirp(projectDir, function(err) {
if (err) console.error("创建失败");
else {
console.log(`创建${name}文件夹成功`);
const { template, dir, name: fileName } = require("../generator/package")(name);
fs.writeFile(path.join(projectDir, dir, fileName), template.trim(), function(err) {
if (err) console.error(`创建${fileName}文件失败`);
else {
console.log(`创建${fileName}文件成功`);
}
});
}
});
};
[代码]
这里只写了一个模板的创建,我们可以用[代码]readdir[代码]来获取目录下所有文件来遍历执行
下载依赖
我们平常下载[代码]npm[代码]包都是使用命令行 [代码]npm install / yarn install[代码]
这时就需要用到 [代码]node[代码] 的 [代码]child_process.spawn[代码] api 来调用系统命令
因为考虑到跨平台兼容处理,所以使用 cross-spawn 库,来帮我们兼容的操作命令
我们创建[代码]utils[代码]文件夹,创建[代码]install.js[代码]
[代码]utils/install.js[代码]:
[代码]const spawn = require("cross-spawn");
module.exports = function install(options) {
const cwd = options.cwd || process.cwd();
return new Promise((resolve, reject) => {
const command = options.isYarn ? "yarn" : "npm";
const args = ["install", "--save", "--save-exact", "--loglevel", "error"];
const child = spawn(command, args, { cwd, stdio: ["pipe", process.stdout, process.stderr] });
child.once("close", code => {
if (code !== 0) {
reject({
command: `${command} ${args.join(" ")}`
});
return;
}
resolve();
});
child.once("error", reject);
});
};
[代码]
然后我们就可以在创建完模板后调用[代码]install[代码]方法下载依赖
[代码]install({ cwd: projectDir });
[代码]
要知道工作区为我们项目的目录
至此,解析 cli,创建目录,创建模板,下载依赖一套流程已经完成
基本功能都跑通之后下面就是要填充剩余代码和优化
优化
当代码写的多了之后,我们看上面[代码]create[代码]方法内的回调嵌套回调会非常难受
[代码]node 7[代码]已经支持[代码]async,await[代码],所以我们将上面代码改成[代码]Promise[代码]
在[代码]utils[代码]目录下创建,[代码]promisify.js[代码]:
[代码]module.exports = function promisify(fn) {
return function(...args) {
return new Promise(function(resolve, reject) {
fn(...args, function(err, ...res) {
if (err) return reject(err);
if (res.length === 1) return resolve(res[0]);
resolve(res);
});
});
};
};
[代码]
这个方法帮我们把回调形式的[代码]Function[代码]改成[代码]Promise[代码]
在[代码]utils[代码]目录下创建,[代码]fs.js[代码]:
[代码]const fs = require(fs);
const promisify = require("./promisify");
const mkdirp = require("mkdirp");
exports.writeFile = promisify(fs.writeFile);
exports.readdir = promisify(fs.readdir);
exports.mkdirp = promisify(mkdirp);
[代码]
将[代码]fs[代码]和[代码]mkdirp[代码]方法改造成[代码]promise[代码]
改造后的[代码]create.js[代码]:
[代码]const path = require("path");
const fs = require("../utils/fs-promise");
const install = require("../utils/install");
module.exports = async function(name) {
const projectDir = path.join(process.cwd(), name);
await fs.mkdirp(projectDir);
console.log(`创建${name}文件夹成功`);
const { template, dir, name: fileName } = require("../generator/package")(name);
await fs.writeFile(path.join(projectDir, dir, fileName), template.trim());
console.log(`创建${fileName}文件成功`);
install({ cwd: projectDir });
};
[代码]
结语
关于进一步优化:
更多功能与健壮 例如指定目录创建项目,目录不存在等情况
[代码]chalk[代码]和[代码]ora[代码]优化[代码]log[代码],给用户更好的反馈
通过[代码]inquirer[代码]问询用户得到更多的选择:模板[代码]vue-router[代码],[代码]vuex[代码]等更多初始化模板功能,[代码]eslint[代码]
更多的功能:
内置 webpack 配置
一键发布服务器
其实要学会善用第三方库,你会发现我们上面的每个模块都有第三方库的身影,我们只是将这些功能组装起来,再结合我们的想法进一步封装
虽然有[代码]vue-cli[代码],[代码]create-react-app[代码]这些已有的脚手架,但是我们还是可能在某些情况下需要自己实现脚手架部分功能,根据公司的业务来封装,减少重复性工作,或者了解一下内部原理