自定义脚手架

·  阅读 2084

前言

脚手架已经成为了前端日常工作中必不可少的开发利器,通过它不仅能够减少机械的重复工作,而且还能有效的组织和管理企业的项目模板.

企业内部涵盖的前端项目类型不一,比如针对不同平台有pc端、h5小程序等.不同框架有reactvue技术栈.业务类型又可以将项目区分为后台管理系统门户网站以及数据大屏等.

这么多不同类型的项目,它们采用的技术栈组合也会随着相应的场景搭配.后台管理系统通常会采用vue+Element UI + vuex + ts 搭建项目.手机端项目除了基础的设置,它还需要额外配置不同屏幕下的适配.react native项目搭建之初需要配置好全局通用的方法,比如路由跳转、loading加载、请求方法等.

如果每次开启一个新项目,都需要把项目搭建的环节重复一遍,这不符合程序员的行事风格.

脚手架的出现能够有效的帮助前端开发者管理企业内部各种类型项目模板,比如开发者只需要在命令行输入以下命令:

cli create my-app  
复制代码

cli是我们开发的脚手架工具的名称,create是创建新项目的命令,my-app是给新项目起的名称.

命令行接受了上述命令,立刻列出了企业内所有的项目模板(如下):

* vue2
* vue3
* react-mobile
* CRM
复制代码

开发者只需要按键盘的上下键选择项目模板,再敲击enter键选中就完成了操作.脚手架接下来自动完成以下任务:

  • 根据开发者选择的模板名称,找到对应的githup仓库地址,将其下载到本地
  • 如果出现网络波动下载失败,断连重试5
  • 项目下载成功后,进入根目录打开package.json文件,将项目名称name属性修改为my-app
  • package.json文件修改完后,开始安装依赖,最后运行启动命令启动项目

整个过程开发者只需要简单输入几条命令,新项目从下载、安装到启动全部自动完成.

脚手架除了能帮助我们有效的管理和创建新项目,另外还可以增添其他额外的功能,比如一键命令,脚手架自动帮助我们新建页面或组件.

最终实现效果如下(源代码在文章结尾):

1.gif

实现

Hello world

新建一个项目文件夹mycli,打开文件夹执行命令npm init新建项目.

package.json中新增字段"bin": "./bin/index"(代码如下).

// package.json文件

{
  "name": "mycli",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "bin": "./bin/index",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
复制代码

项目根目录下新建文件bin/index,输入以下代码.

#! /usr/bin/env node指定运行环境为node,下面只写一句测试代码hello world.

#! /usr/bin/env node

console.log("hello world");

复制代码

到此为止就可以直接测试上述代码了.进入项目根目录执行npm link将脚手架链接到全局,再执行mycli命令就能输出"hello world"(如下图所示).

2.gif

commander

上述"hello world"的小案例可以看出,在命令行中一旦输入mycli命令,bin/index内编写的逻辑代码就会被执行.

为了实现脚手架的功能,首先介绍一个第三方类库commander,它被常用来编写各类命令行工具.

commander定义命令(代码如下),program.command定义了create <app-name>命令,其中create是定义的关键字,<app-name>是用户传入的参数.

#! /usr/bin/env node

const { program } = require('commander');

program.version('1.0.0');

//创建新项目的命令
program.command("create <app-name>")
.description("创建一个新项目")
.action((appName)=>{
    console.log(appName);
})

program.parse(process.argv);

复制代码

调用方法如下图所示.mycli create my-app成功调用了定义的命令,最后打印输出appNamemy-app.

program.version用于设置版本号,通过mycli -V可查询.

mycli -h显示帮助信息,上述命令定义的description会在帮助信息显示出来.

3.png

命令行工具除了直接输入命令,还可以附加参数.比如下面使用option定义-t或者--template接受用户传入的参数.

#! /usr/bin/env node

const { program } = require('commander');

program.version('1.0.0');

//创建新项目
program.command("create <app-name>")
.description("创建一个新项目")
.option('-t, --template <template-name>','选择一个模板下载')
.action((appName,options)=>{
    console.log(appName,options.template);
})

program.parse(process.argv);
复制代码

调用方式如下图所示.通过options可以拿到用户传递的参数.

4.png

inquirer

inquirer是命令行与用户展开交互的第三方工具库,通过它提供的Api可以轻松在命令行中输出列表和问题.

观察下面代码.命令行依次向用户提出问题,第一个问题询问用户是否爱吃榴莲,type类型为confirm,用户只需要返回yesno.

第二个问题是询问用户喜欢吃什么水果,需要用户输入答案.(运行效果图如下)

#! /usr/bin/env node

  const inquirer = require('inquirer');

  inquirer.prompt([
    {
      type:"confirm",
      name:"firut",  
      message:"你喜欢吃榴莲吗?"  
    },
    {
        type:"input",
        name:"food",  
        message:"告诉我你喜欢吃什么?"  
    }
  ])
  .then((answers) => {
      console.log(answers);
  })

复制代码

执行结果answers会将用户输入的答案根据name属性排布输出.

5.png

inquirer在开发脚手架的过程中,使用最多的场景是输出列表(代码和运行效果图如下).

type类型为list时,命令行窗口输出一串列表提供给用户选择.用户可以敲击键盘上下键选择不同答案,选定后按下enter键确认答案.

#! /usr/bin/env node

  const inquirer = require('inquirer');

  inquirer.prompt([
    {
        type:"list",
        message:"以下哪位人物的武功最高?",
        name:"master",
        choices:["孙悟空","大鹏金翅","牛魔王","黄袍怪","黄眉大王"] 
    }
  ])
  .then((answers) => {
      console.log(answers); // { master:"大鹏金翅" }
  })

复制代码

6.png

实际场景中,inquirer通常会与commander搭配使用.

创建新项目

回到正题,我们的初步目标是为了让脚手架自动下载新项目并安装依赖和启动应用,首先得创建一个配置文件repo.js,用来存储各类模板的详细信息.(代码如下)

url是项目的githup地址,bootstrap是启动命令,install是安装命令.

以后有新的项目模板,只需要在配置文件的后面加一项即可.

// repo.js 文件

//模板下载地址
exports.config = {
  "vue3":{
    url:"kaygod/vue3-demo",
    bootstrap:"npm run serve"
  },
  "vue":{
    url:"kaygod/vue_demo",
    bootstrap:"yarn serve",
    install:"yarn install"
  }
}
复制代码

脚手架入口文件/bin/index编写代码如下.文件定义了一条创建项目的命令,但具体处理该条命令的逻辑代码都封装到了/bin/actions/create.js文件.

这样做更容易维护代码的结构,以后入口文件定义一条新命令,都可以在/bin/actions新建js文件处理该条命令的逻辑.

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

program.version('1.0.0');

//创建新项目
program.command("create <app-name>")
.description("创建一个新项目")
.action((appName)=>{
    require("./actions/create")(appName);
})

program.parse(process.argv);

复制代码

create.js文件代码如下,创建整个项目的流程都可以在createProject函数中体现.

  • createProject接受项目名称appName.调用inquirer.prompt第一次向用户弹出输入框,要求用户填写项目描述信息.第二次向用户弹出模板列表,要求用户选择模板下载.
  • process.cwd()nodejs提供的api,执行后返回用户运行命令行窗口所在的路径.将此路径与appName拼接得到创建的新项目本地路径.
  • download函数下载项目到本地
  • 项目下载成功后,updatePackage函数修改package.json信息
  • start函数先给新项目安装依赖,再执行启动命令
// /bin/actions/create.js文件

const inquirer = require('inquirer');
const { download } = require("../download");
const { updatePackage } = require("../updatePackage");
const { start } = require("../startProject");
const path = require("path");
const { config } = require("../repo");

async function createProject(appName){

    const prompList = [
        {
            type: 'input',
            name: 'description',
            message: '请输入项目描述信息:',
        },
        {
            type:"list",
            message:"请选择一个模板下载:",
            name:"template_name",
            choices:Object.keys(config) // 从配置文件repo.js中动态获取所有模板的名称
        }
    ];

    const { template_name ,description } = await inquirer.prompt(prompList);

    const project_dir = path.join(process.cwd(),appName); //新建项目的路径

    try {
        await download(template_name,project_dir); // 下载项目到本地
        await updatePackage(project_dir,{name:appName,description,template:template_name}); //修改package.json
        start(project_dir,template_name);// 启动项目    
    } catch (error) {
        console.log(error);
    }
    
}

module.exports = createProject;
复制代码

运行效果图如下:

7.png

download

download函数除了下载项目到本地,它还需要处理下载失败重试下载的情况(代码如下).

download函数实现下载这一部分功能主要依靠download-git-repo第三方库,它提供了api可以方便拉取githup上的仓库源码.

download-git-repo调用形式如:dl(`${url}`,project_dir,async function(err) {}),它的第一个参数是远程仓库的地址(配置文件repo已经配置好了),第二个参数是下载到本地的路径,第三个参数是下载完的回调函数.

如果下载失败,回调函数的err不为空,需要启动下载重试.

download-git-repo还提供了很多其他的下载方式,比如使用git clone、私有仓库等,具体细节可查阅官方文档.

const dl = require('download-git-repo');
const { startLoading,endLoading } = require("./loading");
const { config } = require("./repo");
const fse = require("fs-extra");

let count = 0; //计算下载次数

exports.download = (template_name,project_dir) => {

    return new Promise(async (resolve,reject) => {

        const { url } = config[template_name]; // 模板的下载地址

         // 如果目录非空删除目录内容。如果目录不存在,就创建一个
        await fse.emptyDir(project_dir);

        (function execuate(){
            count++;
            if(count >= 5){
                count = 0;
                reject();
                return;
            }
            startLoading(); //加载中
            dl(`${url}`,project_dir,async function(err) {
              endLoading(); // 关闭加载中
              if (err) {
                  console.log(err);
                  //出现下载错误,延时3秒重新下载3次
                  console.log("\n下载失败,3s后下载重试...\n");
                  await sleep();
                  execuate(); 
              }else{
               resolve(null);
               count = 0;
              }
            })
        })();

    });
};

/**
 * 睡眠
 */
const sleep = (time = 3000) => {
    return new Promise((resolve)=>{
          setTimeout(()=>{
            resolve(null);
          },time)      
    })
}
复制代码

下载的过程往往会因为网络原因变得枯燥漫长,我们需要在界面上显示loading的图案(如下图);

8.gif

loading图案借助ora库可以轻松实现,封装成函数导出给外部调用.

// /bin/loading.js 文件

const ora = require('ora');
const loading = ora('Loading');

exports.startLoading = (text = '加载中...') => {
  loading.text = text;
  loading.color = 'green';
  loading.start();
};

exports.endLoading = () => {
  loading.stop();
};
复制代码

updatePackage

项目成功下载到本地后,我们需要将新项目的package.json修改成如下的形式.

{
	"name": "my-app",
	"version": "0.1.0",
	 ... //省略
	"description": "vue3项目",
	"template": "vue3"
}
复制代码

namedescription替换成用户在脚手架中输入的值,而template存下当前项目对应的项目模板名称(后面使用脚手架创建页面时可以用到此参数).

updatePackage函数依靠fs-extra提供api,先将文件内容读取到内存进行修改,再写入原文件中.

const fse = require('fs-extra');
const path = require("path");

//更改package.json文件
exports.updatePackage = async (dirpath, data) => {
  const filename = path.join(dirpath,'package.json');
  try {
    await fse.ensureFile(filename);
    let packageJson = await fse.readFile(filename);
    packageJson = JSON.parse(packageJson.toString());
    packageJson = { ...packageJson, ...data };
    packageJson = JSON.stringify(packageJson, null, '\t');
    await fse.writeFile(filename, packageJson);
  } catch (err) {
    console.error("\npackage.json文件操作失败!\n");
    throw err;
  }
};
复制代码

start

pacakge.json修改完毕,脚手架需要给项目安装依赖并启动应用(代码如下).

start函数主要做两件事:安装依赖和启动项目.安装依赖通常需要运行命令npm i,而启动项目也需要运行命令.vue项目使用npm run serve启动,react项目使用npm run start启动.

不管是安装依赖还是启动项目,它们都需要执行npm相关命令.nodejs的核心模块child_process可以使脚手架直接运行命令.

不同项目的启动命令可能不同,有的使用npm run serve,有的使用yarn start,这些都可以在配置文件repo.js中写好,导出给start函数调用.

const exec = require('child_process').exec;
const { config } = require("./repo");
/**
 * 安装依赖并启动项目
 */
exports.start = async (path,template_name) => {
  await installLib(path,template_name);
  console.log('项目依赖安装完毕...');
  await startProject(path,template_name);
  console.log('项目启动成功...');
};

const installLib = (path,template_name) => {

  const install_command = config[template_name].install || "npm i"; //安装依赖的命令

  return new Promise((resolve, reject) => {
    const workerProcess = exec(  // 安装依赖
      install_command,
      {
        cwd: path,
      },
      (err) => {
        if (err) {
          console.log(err);
          reject(err);
        } else {
          resolve(null);
        }
      }
    );

    workerProcess.stdout.on('data', function (data) {
      console.log(data);
    });

    workerProcess.stderr.on('data', function (data) {
      console.log(data);
    });
  });
};

const startProject = (path,template_name) => {

  const bootstrap_command = config[template_name].bootstrap || "npm run serve"; //启动项目的命令

  return new Promise((resolve, reject) => {
    const workerProcess = exec( // 启动项目
      bootstrap_command,
      {
        cwd: path,
      },
      (err) => {
        if (err) {
          console.log(err);
          reject(err);
        } else {
          resolve(null);
        }
      }
    );

    workerProcess.stdout.on('data', function (data) {
      console.log(data);
    });

    workerProcess.stderr.on('data', function (data) {
      console.log(data);
    });
  });
};

复制代码

创建页面

脚手架除了做基础的创建新项目的工作,还可以根据实际需求做出更多的拓展.

入口文件/bin/index新定义一条命令newpage(代码如下),用来给项目新建页面.

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

program.version('1.0.0');

//创建新项目
program.command("create <app-name>")
.description("创建一个新项目")
.option('-t, --template <template-name>','选择一个模板下载')
.action((appName,options)=>{
    require("./actions/create")(appName,options);
})

//创建新页面
program.command("newpage <page-name>")
.description("创建一个新页面")
.action((pageName)=>{
    require("./actions/newpage")(pageName);
})


program.parse(process.argv);
复制代码

效果图如下:

9.gif

newpage函数代码如下,以vue3项目模板为例,执行命令后在项目/src/views文件夹下新增页面.

newPage函数首先会读取项目的package.json文件中的template字段,这个字段是前面创建项目后脚手架给package.json添加的模板名称.

通过template字段,我们就可以知道当前项目属于哪一种模板类型.然后使用策略模式针对不同的模板编写创建新页面的逻辑.

例如下面代码定义了一个vue3Handler函数,它能够为使用vue3模板下载的项目新建页面.

const Mustache = require('mustache'); // 模板引擎
const path = require("path");
const fse = require("fs-extra");

/**
 * 创建新页面
 */
async function newPage(page_name){ //创建的页面名称
  try {
    const packageJson = await fse.readFile("./package.json");
    const { template } = JSON.parse(packageJson.toString());// 获取模板名称
    const fn = eval(`${template}Handler`);
    fn && fn(page_name,template);
  } catch (error) {
     console.log("\n请在项目根路径下执行此命令!\n");
     throw error;
  }
}

/**
 * 创建vue3模板的新页面
 */
const vue3Handler = async (page_name,template)=>{
 let template_content =  await fse.readFile(path.join(__dirname,`../template/${template}/index`));
  template_content = template_content.toString();
  const result = Mustache.render(template_content,{
    page_name
  });
  //开始创建文件 
  await fse.writeFile(path.join("./src/views",`${page_name}.vue`), result);
  console.log("\n页面创建成功!\n");
} 


module.exports = newPage;
复制代码

vue3Handler函数首先会读取/template/vue3/index下放置的vue3模板文件(代码如下).

将模板代码转化成字符串赋值给template_content变量,再使用模板引擎Mustache将模板中name属性对应的页面名称修改成用户敲击newpage命令时输入的值.

修改完成后,再将内存中的新文件内容输出到/src/views文件下生成.

<template>
  <div class="container"></div>
</template>

<script lang='ts'>
import {
  reactive,
  toRefs,
  onBeforeMount,
  onMounted,
  defineComponent,
} from 'vue'

interface DataProps {}
export default defineComponent({
  name: '{{page_name}}',
  setup() {
    return {
    }
  },
})
</script>
<style scoped lang="less">
.container{}
</style>
复制代码

受此启发,脚手架工具还可以定义更多的逻辑去完成更多的需求.比如newpage命令不光光只是在views文件夹下新建一个页面,它还可以自动将新建的页面配置插入到路由和vuex中去,真正实现一键命令就可以在浏览器上看到结果.

最后将工具发布到npm,就可以分享给其他成员使用了.

源代码

源代码

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改