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


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 相关文章推荐
jQuery实现用方向键控制层的上下左右移动
Jan 13 Javascript
实现局部遮罩与关闭原理及代码
Feb 04 Javascript
javascript避免数字计算精度误差的方法详解
Mar 05 Javascript
js数组操作常用方法
May 08 Javascript
调整小数的格式保留小数点后两位
May 14 Javascript
JavaScript实现找出字符串中第一个不重复的字符
Sep 03 Javascript
js带点自动图片轮播幻灯片特效代码分享
Sep 07 Javascript
Javascript设计模式理论与编程实战之简单工厂模式
Nov 03 Javascript
谈谈javascript中使用连等赋值操作带来的问题
Nov 26 Javascript
Treegrid的动态加载实例代码
Apr 29 Javascript
vue element table 表格请求后台排序的方法
Sep 28 Javascript
vue二维数组循环嵌套方式 循环数组、循环嵌套数组
Apr 24 Vue.js
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
javascript编程起步(第七课)
2007/01/10 Javascript
JavaScript 实现模态对话框 源代码大全
2009/05/02 Javascript
深入document.write()与HTML4.01的非成对标签的详解
2013/05/08 Javascript
jquery中加载图片自适应大小主要实现代码
2013/08/23 Javascript
node.js中的url.format方法使用说明
2014/12/10 Javascript
html的DOM中Event对象onabort事件用法实例
2015/01/21 Javascript
jquery实现可横向和竖向展开的动态下滑菜单效果
2015/08/24 Javascript
详解JavaScript的另类写法
2016/04/11 Javascript
Easyui Treegrid改变默认图标的方法
2016/04/29 Javascript
Node.js中防止错误导致的进程阻塞的方法
2016/08/11 Javascript
html+javascript+bootstrap实现层级多选框全层全选和多选功能
2017/03/09 Javascript
Vue.js 60分钟快速入门教程
2017/03/28 Javascript
node.js连接MongoDB数据库的2种方法教程
2017/05/17 Javascript
node.js利用socket.io实现多人在线匹配联机五子棋
2018/05/31 Javascript
Python模仿POST提交HTTP数据及使用Cookie值的方法
2014/11/10 Python
python使用PyGame播放Midi和Mp3文件的方法
2015/04/24 Python
举例讲解Python中的死锁、可重入锁和互斥锁
2015/11/05 Python
python3 字符串/列表/元组(str/list/tuple)相互转换方法及join()函数的使用
2019/04/03 Python
Python使用pandas和xlsxwriter读写xlsx文件的方法示例
2019/04/09 Python
Python切片操作去除字符串首尾的空格
2019/04/22 Python
利用python3筛选excel中特定的行(行值满足某个条件/行值属于某个集合)
2020/09/04 Python
Pycharm2020最新激活码|永久激活(附最新激活码和插件的详细教程)
2020/09/29 Python
Python: glob匹配文件的操作
2020/12/11 Python
Opodo英国旅游网站:预订廉价航班、酒店和汽车租赁
2018/07/14 全球购物
意大利自行车商店:Cingolani Bike Shop
2019/09/03 全球购物
Fenty Beauty官网:蕾哈娜创立的美妆品牌
2021/01/07 全球购物
中兴通讯全球官方网站:ZTE
2020/12/26 全球购物
自荐信格式的六要素
2013/09/21 职场文书
汽车维修专业个人求职信范文
2014/01/01 职场文书
求职信需要的五点内容
2014/02/01 职场文书
2014年大班元旦活动方案
2014/02/26 职场文书
国培计划培训感言
2014/03/11 职场文书
暑期政治学习心得体会
2014/09/02 职场文书
法院执行局工作总结
2015/08/11 职场文书
小学体育组工作总结
2015/08/13 职场文书
《确定位置》教学反思
2016/02/18 职场文书