阅读 231

大前端进阶系列——从零开始实现属于自己的脚手架

前言

随着前端开发越来越复杂,随之出现了大量的 js 框架,几乎每一家都配有配套的构建工具诸如 vue-cli、 create-react-ap、@angular/cli、@nestjs/cli等。这些脚手架可以快速帮助开发者初始化配置、目录结构搭建、项目构建。尽管这些脚手架都是相当优秀,几乎可以满足大部分的开发需求,但在特定的开发场景中可能需要根据业务需求做一定的调整,这就需要对脚手架内部的运行机制有一定的了解。

本文的脚手架已经发布到 npm, 同时也欢迎开发者提供优秀的模板。

npm

t-cli 说明文件

通过这篇文章,能够有这些收获:

  • 如何设计属于自己的脚手架工具
  • 发布属于自己的 npm 包
  • 大前端领域中脚手架的整体架构

CLI

什么是 cli

搜索引擎给出的解释如下:

命令行界面(英语:command-line interface,缩写:CLI)是在图形用户界面得到普及之前使用最为广泛的用户界面,它通常不支持鼠标,用户通过键盘输入指令,计算机接收到指令后,予以执行。也有人称之为字符用户界面(CUI)。通常认为,命令行界面(CLI)没有图形用户界面(GUI)那么方便用户操作

为什么要使用 cli

  • 减少重复性工作(webpack配置,目录、路由等配置信息)
  • 规范团队代码风格、目录层级(统一 eslint 、git 配置)
  • 统一插件、依赖版本、避免未知依赖错误

基本流程

目前主流的脚手架内部的实现原理可能会有所差别但最终实现的功能相差无几,主要功能包括:

项目搭建

  • 与用户交互产生获取项目配置信息
  • 下载 / 生成 项目模板
  • 生产、开发环境依赖安装

开发环境

  • 本地开发(热更新、接口代理等)
  • eslint 代码风格检测、修复

项目构建

  • build 项目
  • 项目部署
  • 依赖分析

这个步骤中的 构建、部署也可以采用 Webhook + Jenkins 实现自动化部署,通过给远程仓库配置一定的触发规则,当有用户 push 后, 会自动进行 build、部署。

前期准备

依赖插件准备

  • babel:语法转换工具
  • commander: 命令行工具,通过它可以读取命令行命令
  • inquirer:用户-计算机命令行交互工具
  • download-git-repo:git 文件夹下载
  • chalk: 颜色插件,用来修改命令行输出样式,通过颜色区分 info、error 日志
  • ora: 加载效果插件
  • gulp 构建工具

依赖除上述以外还包括其他一些开发环境依赖,完整代参考t-cli-github

工程模板

脚手架可以快速生成项目结构和配置,最常用的方式就是我们提前准备好一套通用的规范的项目模板存放在指定位置,在脚手架执行创建项目命令的时候,直接将准备好的模板复制到目录下。存储模板的位置一般会选择 git 仓库,一是后期方便升级、维护‘二是打出来的 npm 包不至于太大。

npm发包

准备一个 npm 账号,如果没有请到官网注册。

  • 项目初始化:创建目录执行npm init , 生成 package.json,其中 name 就是包名称,为防止自己喜欢的名称和已有的包名冲突可以采用 scope 方式发包,例如@canyuegongzi/t-cli
  • 登录 : npm login
  • 发布: npm publish,如果包名是非 scope 可以直接采用这条指令发布,如果是 scope 方式需要添加参数,完整指令如下 npm publish --access=public

至此,发布了一个属于自己的 npm 包,只不过内容空空的。

备注:发布包一定要在 npm 源下,taobao、cnpm环境都会报错

环境搭建

一:创建项目 t-cli 目录,执行 npm init,生成 package.json,修改 name 为 @canyuegongzi/t-cli。

本来名称叫 t-cli, 但这个名称已经被注册过了,所以只能采用 scope 方式。

二:修改 修改 package.json 中的 bin 参数,指向 入口文件。

bin": {
    	"t": "src/cli/bin/t.js"
  },
复制代码

三:搭建 gulp 构建环境

关于为什么采用gulp ,而不是采用 webpack 或 rollupjs:构建工具可以随便,这里只需要 es6 转换和文件复制,可以自由选择。

const gulp = require('gulp');
const babel = require('gulp-babel');

gulp.task("babel", function () {
    return gulp.src("./src/**/*.js")
        .pipe(babel({
            "presets": [
                "@babel/preset-env"
            ],
            "plugins": [
                "@babel/plugin-proposal-object-rest-spread",
                "@babel/plugin-transform-runtime"
            ]
        }))
        .pipe(gulp.dest("cli_dist"))
})
gulp.task("copy-config", function () {
    return gulp.src(["./src/cli/config/*.json"])
        .pipe(gulp.dest("./cli_dist/cli/config"))
})
gulp.task('default', gulp.series('babel', 'copy-config'));
复制代码

修改 package.json scripts 脚本

  "scripts": {
    "build": "gulp default",
    "test": "jest"
  },
复制代码

目录搭建

├── src                               
|——|—— cli                            
|——|—— |—— bin
|——|—— | ——|—— t.js                  // 系统入口文件
|——|—— |—— config                              
|——|—— | ——|—— category.json            // 系统模板类型配置信息
|——|—— | ——|—— template.json            // 系统模板模板信息
|——|—— |—— lib                          
|——|—— | ——|—— init.js               // init 指令
|——|—— | ——|—— list.js               // list 指令
|——|—— | ——|—— update.js             // update 指令
|——|—— |—— utils                  
|——|—— | ——|—— download.js            // 文件下载
|——|—— | ——|—— error.js                    
|——|—— | ——|—— log.js                       
├── test                           // 测试用例
|—— .npmignore                       // npm 包发忽略文件
|—— .babelrc                        // babel 配置
|—— .gulpfile.js                    // gulp 配置
|—— package.json                    // 开发配置
|—— jest.config.js                   // 测试配置

复制代码

init 指令

入口文件申明命令行,入口文件必须以#!/usr/bin/env node 声明。

采用 commander 来设置不同的命令。command 方法设置命令的名字、description 方法是设置命令的描述、alias 方法设置命令简称、options 设置命令需要的参数。commander官网查看

命令申明

当用户调用init <app-name> 命令创建工程模板时会调用 action 选项中的回调函数 create 函数。

#!/usr/bin/env node
const program = require('commander')

program
    .version(`@canyuegongzi/t-cli ${require('../../../package').version}`)
    .usage('<command> [options]')

// 申明 init 命令 并且声明两个参数 -c 和 -t,其中 action 中的回调函数就是用户调用 init 时需要执行的函数
program
    .command('init <app-name>')
    .description('初始化一个工程')
    .option('-c, --category <category>', '工程类型,[web | server]')
    .option('-t, --template <template>', '模板名称')
    .action((name, options) => {
        require('../lib/init').create(name, options).then(r => {})
    })

复制代码

选择模板类型(web OR server)

用户在调用 init 命令时,如果未传入 -c参数的情况下需要用户选择模板类型。

选择模板类型

在需要与用户交互时就需要之前提到的 inquirer 插件了,具体代码实现如下:

/**
 * 选择工程类型
 * @returns {Promise<void>}
 */
async function selectCategory() {
    return new Promise(resolve => {
        inquirer.prompt([
            { type: 'list', message: 'please select category:', name: category, choices: categoryList }
        ]).then((answers) => {
            console.log(answers);
            resolve(answers[category])
        })
    })
}
复制代码

选择模板

用户在调用 init 命令时,如果未传入 -t(未指定模板)参数的情况下需要用户选择模板。

选择模板

在用户选择模板前需要根据模板类型对全部的模板过滤一遍,具体代码实现如下:

/**
 * 选择工程模板名称
 * @returns {Promise<void>}
 */
async function selectTemplate(projectCategory) {
    try {
		// 根据模板类型筛选模板
        const list = templateList.filter(item => item.type === projectCategory).map((item) => item.name)
        if (!list.length || !list) {
			// 没有模板时给用户一个提示
            return log('WARING', 'no template');
        }
        return new Promise(resolve => {
            inquirer.prompt([
                { type: 'list', message: 'please select template:', name: template, choices: list }
            ]).then((answers) => {
                resolve(answers[template])
            })
        })
    }catch (e){
        log('ERROR', e);
    }

}

复制代码

项目信息收集

每个项目都有 package.json, 在初始化时也需要用户手动输入,并通过 node 提供的文件系统修改文件信息,该案例中实现较为简单,只需要用户输入 name、version、description。

项目信息收集

实现代码如下:

/**
 * 用户自己输入一些配置信息
 * @param name
 * @returns {Promise<void>}
 */
async function getUserInputPackageMessage(name) {
    return new Promise(async (resolve, reject) => {
        if(isTest) {
            return resolve({name, author: '', description: '', version: '1.0.0' })
        }
        try {
			// 提示用户依次输入 name、version、description
            const messageInfoList = await Promise.all([
                inquirer.prompt([
                    { type: 'input', message: "what's your name?", name: 'author', default: '' },
                    { type: 'input', message: "please enter version?", name: 'version', default: '1.0.0' },
                    { type: 'input', message: "please enter description.", name: 'description', default: '' },
                ])
            ]);
            resolve({...messageInfoList[0], name});
        }catch (e) {
            resolve({name, author: '', description: '', version: '1.0.0' })
        }
    })
}
复制代码

文件下载实现

这里也是采用的之前提到的 ownload-git-repo 插件进行模板下载。具体实现代码如下:

/**
 * 下载文件到目录
 * @param url
 * @param name
 * @param target
 * @returns {Promise<void>}
 */
async function downloadFile(url, name, target = process.cwd()) {
    return new Promise((resolve, reject) => {
        const dir = path.join(target, name);
		// 有这个目录名的话直接删除
        rimraf.sync(dir, {});
        const downLoadCallback = (err) => {
            if (err) {
                resolve({flag: false, dir, name});
                log('ERROR', err);
            }
			// 下载成功后返回目录
            resolve({flag: true, dir, name});
        }
        download(url, dir, {clone: true}, downLoadCallback);
    })

}
复制代码

init 项目

用户调用 init 命令时大致流程如下,先获取模板信息然后下载再修改文件信息。

init流程图

init 流程代码实现如下:

/**
 * 初始化工程模板
 * @param pluginToAdd
 * @param options
 * @param context
 * @returns {Promise<void>}
 */
async function init (pluginToAdd, options = {}, context = process.cwd()) {
    let projectCategory = options[category]
    let projectTemplate = options[template]
    let projectName = pluginToAdd;
	// 用户未传参 -c 时 需要用户选择模板类型
    if (!options.hasOwnProperty(category)){
        projectCategory = await selectCategory()
    }
	// 用户未传参 -t 时 需要用户选择模板
    if (!options.hasOwnProperty(template)){
        projectTemplate = await selectTemplate(projectCategory)
    }
	// 根据用户选择的模板类型和模板获取模板地址
    const templateInfo = templateList.find((item) => item.type === projectCategory && item.name === projectTemplate);
    if (!templateInfo) {
        return log('WARING', 'no template');
    }
    const {url} = templateInfo;
	// 获取用户输入的项目的工程信息
    const packageInfo = await getUserInputPackageMessage(projectName);
	// 开始一个下载中的图标提示
    const downloadSpinner = ora({ text: 'start download template...', color: 'blue'}).start();
	// 根据模板地址进行下载到当前模板
    const {dir, name, flag} = await downloadFile(url[0], projectName, context)
    if (flag) {
		// 下载成功后结束下载图标
        downloadSpinner.succeed('download success');
        const editConfigSpinner = ora({ text: 'start edit config...', color: 'blue'}).start();
        // 下载完成后修改配置信息
        const successFlag = await downloadSuccess(dir, name, packageInfo);
        if (successFlag) {
            editConfigSpinner.succeed('create success');
        }else {
            editConfigSpinner.fail('create fail');
        }
    } else {
        downloadSpinner.fail('download fail');
    }
}
复制代码

脚手架创建项目后需要根据之前收集的项目信息进行修改。

/**
 * 模板下载成功
 * @param dir
 * @param name
 * @param packageInfo
 * @returns {Promise<void>}
 */
async function downloadSuccess(dir, name, packageInfo) {
    return new Promise((resolve) => {
		// 读 package.json
        fs.readFile(dir + '/package.json', 'utf8', (err, data) => {
            if (err) {
                resolve(false);
            }
            const packageFile = {...JSON.parse(data), ...packageInfo}
			// 修改配置信息并重新写入
            fs.writeFile(dir + '/package.json', JSON.stringify(packageFile, null, 4), 'utf8', (err) => {
                if (err) {
                    resolve(false);
                }
                resolve(true);
            });
        })
    })
}
复制代码

list指令

命令申明

list 命令主要用户查询当前脚手架支持的工程模板,会调用 action 选项中的回调函数,该命令支持 -c参数,可选值包含 web 和 serve。

program
    .command('list')
    .description('列出项目模板')
    .option('-c, --category <category>', '工程类型,[web | server]')
    .option('-q, --query <query>', '查询字符串')
    .action((options) => {
        require('../lib/list')(options)
    })
复制代码

模板查询

/**
 * 列出模板列表
 * @param options
 * @param context
 * @returns {Promise<void>}
 */
async function list (options = {}, context = process.cwd()) {
    let projectCategory = options[category];
    let projectQuery = options[query];
    let templateLogList = templateList;
    if (projectCategory){
		// 根据模板类型进行第一次筛选
        templateLogList = templateList.filter(item => item.type === projectCategory);
    }
    if (projectQuery) {
		// 根据模板名查询模板列表
        templateLogList = templateLogList.filter(item => item.name.indexOf(projectQuery) > -1)
    }
	// 打印模板信息
    for (let i = 0; i < templateLogList.length; i ++) {
        const str = `${templateLogList[i].name}`;
        log('TEXT', str );
    }
    if (!templateLogList.length) {
        log('WARING', 'No matching template !!!');
    }
	// 打印完成后结束掉程序
    process.exit(0);
}
复制代码

update指令

命令申明

update 主要用于模板列表更新, 该命令可以在不用升级脚手架的情况下获取最新的模板。

program
    .command('update')
    .description('更新配置')
    .option('-t, --type <type>', '更新类型,[config]')
    .action((options) => {
        require('../lib/update')(options)
    })
复制代码

获取最新的配置信息

/**
 * 获取模板
 * @param options
 * @param context
 * @returns {Promise<void>}
 */
async function getList(options = {}, context = process.cwd()) {
    return new Promise(resolve => {
		// 调用 http 服务
        https.get(configUrl, (response) => {
            let data = '';
            response.on('data', (chunk) => {
                data += chunk;
            });
            response.on('end', () => {
                resolve(JSON.parse(data));
            });

        }).on("error", (error) => {
            log('ERROR', error.message);
        });
    })
}
复制代码

修改配置文件

这部分代码就是些简单的通过 node 进行文件操作,再不一一讲解,源码

最后

文章篇幅有限,不能对每一行代码进行讲解,感兴趣的同学可以克隆代码自己实现一遍。

本文通过以上内容完整的实现了一个配置性较高的脚手架,这个脚手架或许不适合每个开发环境,但通过文章可以梳理出脚手架的工作原理。有了一定的基础,后期可以慢慢扩展功能。

github.com/canyuegongz…

文章分类
前端
文章标签