从零搭一个自用的前端脚手架的方法步骤


Posted in Javascript onSeptember 23, 2019

为什么要弄个脚手架

对于我个人,经常写些demo,或者写一个新项目的时候,要么就是把以前的项目模板复制一份,要么就是重新搭建一份,显得比较麻烦,浪费时间,所以就有了搭建一个能满足自己需要的脚手架。

脚手架的效果

从零搭一个自用的前端脚手架的方法步骤

这是一个基本的脚手架,init一个项目,输入项目名称,版本号等信息,然后从git仓库拷贝一份自己需要的项目模板。类似vue的vue-cli或者react的create-react-app,只是这个比较简单.

基本思路参考下图

从零搭一个自用的前端脚手架的方法步骤

这部分参考了掘金@张国钰大佬的思路.

项目结构

从零搭一个自用的前端脚手架的方法步骤

主要3个,一个bin文件夹,放执行命令的入口文件

lib文件夹,放项目的主要文件,package.json不多说

这项目主要用到的几个包

  • commander: 命令行工具
  • download-git-repo: 用来下载远程模板
  • ora: 显示loading动画
  • chalk: 修改控制台输出内容样式
  • log-symbols: 显示出 √ 或 × 等的图标
  • inquirer.js:命令交互
  • metalsmith:处理项目模板
  • handlebars:模板引擎

使用commander.js命令行工具

修改package.json的bin执行入口,

"bin": {
  "lz": "./bin/www"
 },

"lz"这个命令可以自己选择,然后在bin文件加创建名为www的文件,

#! /usr/bin/env node
require('../lib/index.js');

其中

#! /usr/bin/env node

不能少,这个主要指定当前脚本由node.js进行解析

在lib创建一个index.js文件,

const program = require('commander')

program.version('1.0.0')
  .usage('<command> [项目名称]')
  .command('init', '创建新项目')
 .parse(process.argv);

为方便测试,先链接到全局环境

npm link

执行下命令感受下

lz init hello

正常来说,应该就报错了,错误堆栈大概就是确实www-init文件,

这是因为
commander支持git风格的子命令处理,可以根据子命令自动引导到以特定格式命名的命令执行文件,文件名的格式是[command]-[subcommand],例如:

macaw hello => macaw-hello
macaw init => macaw-init

所以我们 执行www文件的init,所以要在bin创建一个www-init文件,在lib创建个init.js文件
www-init

#! /usr/bin/env node
require('../lib/init.js');

init.js 完整代码

const program = require('commander')
const path = require('path')
const fs = require('fs')
const glob = require('glob') // npm i glob -D
const download = require('../lib/download.js')
const inquirer = require('inquirer')
const chalk = require('chalk')
const generator = require('../lib/generator')
const logSymbols = require("log-symbols");

 
program.usage('<project-name>')

// 根据输入,获取项目名称
let projectName = process.argv[2];

if (!projectName) { // project-name 必填
 // 相当于执行命令的--help选项,显示help信息,这是commander内置的一个命令选项
 program.help()
 return
}

const list = glob.sync('*') // 遍历当前目录

let next = undefined;

let rootName = path.basename(process.cwd());
if (list.length) { // 如果当前目录不为空
 if (list.some(n => {
  const fileName = path.resolve(process.cwd(), n);
  const isDir = fs.statSync(fileName).isDirectory();
  return projectName === n && isDir
 })) {
  console.log(`项目${projectName}已经存在`);
  return;
 }
 rootName = projectName;
 next = Promise.resolve(projectName);
} else if (rootName === projectName) {
 rootName = '.';
 next = inquirer.prompt([
  {
   name: 'buildInCurrent',
   message: '当前目录为空,且目录名称和项目名称相同,是否直接在当前目录下创建新项目?',
   type: 'confirm',
   default: true
  }
 ]).then(answer => {
  return Promise.resolve(answer.buildInCurrent ? '.' : projectName)
 })
} else {
 rootName = projectName;
 next = Promise.resolve(projectName)
}

next && go()

function go() {
 next
  .then(projectRoot => {
   if (projectRoot !== '.') {
    fs.mkdirSync(projectRoot)
   }
   return download(projectRoot).then(target => {
    return {
     name: projectRoot,
     root: projectRoot,
     downloadTemp: target
    }
   })
  })
  .then(context => {
   return inquirer.prompt([
    {
     name: 'projectName',
     message: '项目的名称',
     default: context.name
    }, {
     name: 'projectVersion',
     message: '项目的版本号',
     default: '1.0.0'
    }, {
     name: 'projectDescription',
     message: '项目的简介',
     default: `A project named ${context.name}`
    }
   ]).then(answers => {
    return {
     ...context,
     metadata: {
      ...answers
     }
    }
   })
  })
  .then(context => {
   //删除临时文件夹,将文件移动到目标目录下
   return generator(context);
  })
  .then(context => {
   // 成功用绿色显示,给出积极的反馈
   console.log(logSymbols.success, chalk.green('创建成功:)'))
   console.log(chalk.green('cd ' + context.root + '\nnpm install\nnpm run dev'))
  })
  .catch(err => {
   // 失败了用红色,增强提示
   console.log(err);
   console.error(logSymbols.error, chalk.red(`创建失败:${err.message}`))
  })
}

init.js都做了什么呢?

首先,获得 init 后面输入的参数,作为项目名称,当然判断这个项目名称是否存在,然后进行对应的逻辑操作,通过download-git-repo工具,下载仓库的模板,然后通过inquirer.js 处理命令行交互,获得输入的名称,版本号能信息,最后在根据这些信息,处理模板文件。

用download-git-repo下载模板文件

在lib下创建download.js文件

const download = require('download-git-repo')
const path = require("path")
const ora = require('ora')

module.exports = function (target) {
 target = path.join(target || '.', '.download-temp');
 return new Promise(function (res, rej) {
  // 这里可以根据具体的模板地址设置下载的url,注意,如果是git,url后面的branch不能忽略
  let url='github:ZoeLeee/BaseLearnCli#bash';
  const spinner = ora(`正在下载项目模板,源地址:${url}`)
  spinner.start();

  download(url, target, { clone: true }, function (err)
  {
    if (err) {
      download(url, target, { clone: false }, function (err)
      {
        if (err) {
          spinner.fail();
          rej(err)
        }
        else {
          // 下载的模板存放在一个临时路径中,下载完成后,可以向下通知这个临时路径,以便后续处理
          spinner.succeed()
          res(target)
        }
      })
    }
    else {
      // 下载的模板存放在一个临时路径中,下载完成后,可以向下通知这个临时路径,以便后续处理
      spinner.succeed()
      res(target)
    }
  })
 })
}

这里注意下下载地址的url,注意url的格式,不是git clone 的那个地址。其中有个clone:false这个参数,如果只是个人用,可以为true,这样就相当于执行的git clone的操作,如果给其他人,可能会出错,用false的话,那个就是直接用http协议去下载这个模板,具体可以去看官网的文档.

inquirer.js 处理命令交互

比较简单,可以看init.js

从零搭一个自用的前端脚手架的方法步骤

这里把获取到的输入信息在往下传递去处理。

metalsmith

接着,要根据获取到的信息,渲染模板。

首先,未不影响原来的模板运行,我们在git仓库上创建一个package_temp.json,对应上我们要交互的变量名

{
 "name": "{{projectName}}",
 "version": "{{projectVersion}}",
 "description": "{{projectDescription}}",
 "main": "./src/index.js",
 "scripts": {
  "dev": "webpack-dev-server --config ./config/webpack.config.js",
  "build": "webpack --config ./config/webpack.config.js --mode production"
 },
 "author": "{{author}}",
 "license": "ISC",
 "devDependencies": {
  "@babel/core": "^7.3.3",
  "@babel/preset-env": "^7.3.1",
  "@babel/preset-react": "^7.0.0",
  "babel-loader": "^8.0.5",
  "clean-webpack-plugin": "^1.0.1",
  "css-loader": "^2.1.0",
  "html-webpack-plugin": "^3.2.0",
  "style-loader": "^0.23.1",
  "webpack": "^4.28.2",
  "webpack-cli": "^3.1.2",
  "webpack-dev-server": "^3.2.0"
 },
 "dependencies": {
  "react": "^16.8.2",
  "react-dom": "^16.8.2"
 }
}

在lib下创建generator.js文件,用来处理模板

const Metalsmith = require('metalsmith')
const Handlebars = require('handlebars')
const remove = require("../lib/remove")
const fs = require("fs")
const path = require("path")

module.exports = function (context) {
 let metadata = context.metadata;
 let src = context.downloadTemp;
 let dest = './' + context.root;
 if (!src) {
  return Promise.reject(new Error(`无效的source:${src}`))
 }

 return new Promise((resolve, reject) => {
  const metalsmith = Metalsmith(process.cwd())
   .metadata(metadata)
   .clean(false)
   .source(src)
   .destination(dest);
  // 判断下载的项目模板中是否有templates.ignore
  const ignoreFile = path.resolve(process.cwd(), path.join(src, 'templates.ignore'));

  const packjsonTemp = path.resolve(process.cwd(), path.join(src, 'package_temp.json'));

  let package_temp_content;

  if (fs.existsSync(ignoreFile)) {
   // 定义一个用于移除模板中被忽略文件的metalsmith插件
   metalsmith.use((files, metalsmith, done) => {
    const meta = metalsmith.metadata()
    // 先对ignore文件进行渲染,然后按行切割ignore文件的内容,拿到被忽略清单
    const ignores = Handlebars
     .compile(fs.readFileSync(ignoreFile).toString())(meta)
     .split('\n').map(s => s.trim().replace(/\//g, "\\")).filter(item => item.length);
    //删除被忽略的文件
    for (let ignorePattern of ignores) {
     if (files.hasOwnProperty(ignorePattern)) {
      delete files[ignorePattern];
     }
    }
    done()
   })
  }
  metalsmith.use((files, metalsmith, done) => {
   const meta = metalsmith.metadata();
   package_temp_content = Handlebars.compile(fs.readFileSync(packjsonTemp).toString())(meta);
   done();
  })

  metalsmith.use((files, metalsmith, done) => {
   const meta = metalsmith.metadata()
   Object.keys(files).forEach(fileName => {
    const t = files[fileName].contents.toString()
    if (fileName === "package.json")
     files[fileName].contents = new Buffer(package_temp_content);
    else
     files[fileName].contents = new Buffer(Handlebars.compile(t)(meta));
   })
   done()
  }).build(err => {
   remove(src);
   err ? reject(err) : resolve(context);
  })
 })
}

通过Handlebars给我们的package_temp.json进行插值渲染,然后把渲染好的文件内容替换掉原先的package.json的内容

其中有时候我们也需要输入选择某些文件不下载,所以,我们在模板仓库加入一个文件,取名templates.ignore,
然后,跟处理package_temp.json类似,优先渲染这个文件内容,找出需要忽略的文件删掉。最后,删除临时文件夹,把文件移动到项目的文件。这样项目就差不多了。
加入删除文件夹得功能,在lib创建remove.js

const fs =require("fs");
const path=require("path");

function removeDir(dir) {
 let files = fs.readdirSync(dir)
 for(var i=0;i<files.length;i++){
  let newPath = path.join(dir,files[i]);
  let stat = fs.statSync(newPath)
  if(stat.isDirectory()){
   //如果是文件夹就递归下去
   removeDir(newPath);
  }else {
   //删除文件
   fs.unlinkSync(newPath);
  }
 }
 fs.rmdirSync(dir)//如果文件夹是空的,就将自己删除掉
}

module.exports=removeDir;

结尾

关于美化得就不说了,大概的脚手架就以上这些内容,当然这些功能太过于简单,我们还需要根据自己的需要,添加功能,比如说,是否要启用Typescript,要less还是sass等,大概原来差不多,根据输入,选择加载哪些文件,大家自由扩展,谢谢阅读.

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
SUN的《AJAX与J2EE》全文译了
Feb 23 Javascript
左右悬浮可分组的网站QQ在线客服代码(可谓经典)
Dec 21 Javascript
优化javascript的执行效率一些方法总结
Dec 25 Javascript
table行随鼠标移动变色示例
May 07 Javascript
基于JavaScript实现鼠标悬浮弹出跟随鼠标移动的带箭头的信息层
Jan 18 Javascript
实例详解ECMAScript5中新增的Array方法
Apr 05 Javascript
基于jQuery实现弹出可关闭遮罩提示框实例代码
Jul 18 Javascript
详解vue-cli开发环境跨域问题解决方案
Jun 06 Javascript
Vue2.0用 watch 观察 prop 变化(不触发)
Sep 08 Javascript
Vue v2.4中新增的$attrs及$listeners属性使用教程
Jan 08 Javascript
微信分享invalid signature签名错误踩过的坑
Apr 11 Javascript
微信小程序自定义弹出层效果
May 26 Javascript
layui 实现加载动画以及非真实加载进度的方法
Sep 23 #Javascript
layui加载数据显示loading加载完成loading消失的实例代码
Sep 23 #Javascript
ES10的13个新特性示例(小结)
Sep 23 #Javascript
layui-tree实现Ajax异步请求后动态添加节点的方法
Sep 23 #Javascript
vue多页面项目中路由使用history模式的方法
Sep 23 #Javascript
JS随机密码生成算法
Sep 23 #Javascript
详解mpvue开发微信小程序基础知识
Sep 23 #Javascript
You might like
拼音码表的生成
2006/10/09 PHP
PHP聊天室技术
2006/10/09 PHP
Ajax PHP分页演示
2007/01/02 PHP
discuz的php防止sql注入函数
2011/01/17 PHP
smarty基础之拼接字符串的详解
2013/06/18 PHP
php简单计算页面加载时间的方法
2015/06/19 PHP
php使用Header函数,PHP_AUTH_PW和PHP_AUTH_USER做用户验证
2016/05/04 PHP
yii2实现 &quot;上一篇,下一篇&quot; 功能的代码实例
2017/02/04 PHP
PHP静态成员变量
2017/02/14 PHP
PHP有序表查找之二分查找(折半查找)算法示例
2018/02/09 PHP
PHP 实现文件压缩解压操作的方法
2019/06/14 PHP
Javascript 判断函数类型完美解决方案
2009/09/02 Javascript
两个JavaScript jsFiddle JSBin在线调试器
2010/03/14 Javascript
使用DNode实现php和nodejs之间通信的简单实例
2015/07/06 NodeJs
nodejs的压缩文件模块archiver用法示例
2017/01/18 NodeJs
JS常见创建类的方法小结【工厂方式,构造器方式,原型方式,联合方式等】
2017/04/01 Javascript
js数组方法reduce经典用法代码分享
2018/01/07 Javascript
js实现轮播图效果 纯js实现图片自动切换
2020/08/09 Javascript
[01:52]DOTA2完美大师赛Vega战队趣味视频——kpii老师小课堂
2017/11/25 DOTA
使用Python获取CPU、内存和硬盘等windowns系统信息的2个例子
2014/04/15 Python
零基础写python爬虫之HTTP异常处理
2014/11/05 Python
Python+Selenium自动化实现分页(pagination)处理
2017/03/31 Python
python使用pandas处理excel文件转为csv文件的方法示例
2019/07/18 Python
Python 打印自己设计的字体的实例讲解
2021/01/04 Python
美国最灵活的移动提供商:Tello
2017/07/18 全球购物
美国本地交易和折扣网站:LocalFlavor.com
2017/10/26 全球购物
写出程序把一个链表中的接点顺序倒排
2014/04/28 面试题
如何估计一张表的大小(假设该表中有1万条数据)
2016/03/27 面试题
后勤主管工作职责
2013/12/07 职场文书
人力资源经理的岗位职责
2014/03/02 职场文书
代办委托书怎么写
2014/08/01 职场文书
庭外和解协议书
2016/03/23 职场文书
合作意向书范本
2019/04/17 职场文书
常用的Python代码调试工具总结
2021/06/23 Python
Django对接elasticsearch实现全文检索的示例代码
2021/08/02 Python
分析SQL窗口函数之聚合窗口函数
2022/04/21 Oracle