「前端工程化」从0-1搭建react,ts脚手架(1.2w字超详细教程)

一 前言

读完这篇文章你可能会学到哪些知识?html

①node实现终端命令行
②终端命令行交互
③深copy整个文件夹
④nodejs执行终端命令 如 npm install
⑤创建子进程通讯
⑥webpack底层操做,启动webpack,合并配置项
⑦编写一个plugin,理解各阶段
⑧require.context实现前端自动化前端

1 实现效果展现

项目效果

mycli creat 建立项目

mycli start 运行项目

mycli build 打包项目

体验步骤

咱们在这边文章里面用的是mycli ,可是我并无上传项目到npm,可是这篇文章的技术是笔者以前的一个脚手架原型,感兴趣的同窗本地下载能够体验效果。vue

全局下载脚手架rux-clinode

windowsreact

npm install rux-cli -g 
复制代码

macwebpack

sodu npm install rux-cli -g 
复制代码

一条命令建立项目,安装依赖,编译项目,运行项目。git

rux create 
复制代码

2 设置目标

设置目标,分解目标

咱们但愿用一条命令行,实现项目建立依赖下载,项目运行依赖收集等众多流程。若是一口气设计整个功能,可能会感到脑壳一片空白,因此咱们要学会分解目标。实际纵览整个流程,主要分为 建立文件阶段构建,集成webpack阶段运行项目阶段 。梳理每一个阶段咱们须要作的事情。github

二 建立文件阶段

1 终端命令行交互

① node 修改 bin

咱们指望像vue-cli那样 ,经过自定义的命令行vue create,开始建立一个项目,首先可以让程序终端识别咱们的自定义指令,咱们首先须要修改binweb

例子:正则表达式

mycli create 
复制代码

咱们但愿的终端可以识别mycli ,而后经过 mycli create建立一个项目。实际上流程大体是这样的经过mycli能够指向性执行指定的node文件。接下来咱们一块儿分析一下具体步骤。

执行终端命令号,指望结果是执行当前的node文件。

创建工程

如上图所示咱们在终端执行命令行的时候,统一走bin文件夹下面的 mycli.js文件。

mycli.js文件

#!/usr/bin/env node
'use strict';
console.log('hello,world')
复制代码

而后在package.json中声明一下bin

{
  "name": "my-cli",
  "version": "0.0.1",
  "description": "",
  "main": "index.js",
  "bin": {
    "mycli": "./bin/mycli.js"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "👽",
  "license": "ISC",
  "dependencies": {
    "chalk": "^4.0.0",
    "commander": "^5.1.0",
    "inquirer": "^7.1.0",
    "which": "^2.0.2"
  }
}
复制代码

万事俱备,为了在本地调试,my-cli文件夹下用npm link,若是在mac上须要执行 sudo npm link

而后咱们随便新建一个文件夹,执行一下 mycli。看到成功打印hello,world,第一步算是成功了。接下来咱们作的是让node文件(demo项目中的mycli.js)可以读懂咱们的终端命令。好比说 mycli create 建立项目; mycli start运行项目; mycli build 打包项目; 为了可以在终端流利的操纵命令行 ,咱们引入 commander 模块。

② commander -nodejs终端命令行

为了能在终端打印出花里胡哨的颜色,咱们引入chalk库。

const chalk = require('chalk')
const colors = [ 'green' , 'blue' , 'yellow' ,'red'  ]
const consoleColors = {}
/* console color */
colors.forEach(color=>{
    consoleColors[color] = function(text,isConsole=true){
         return isConsole ? console.log( chalk[color](text) ) : chalk[color](text)
    }
})
module.exports = consoleColors
复制代码

接下来须要咱们用 commander 来声明的咱们终端命令。

简介commander经常使用api

Commander.js node.js命令行界面的完整解决方案,受 Ruby Commander启发。 前端开发node cli 必备技能。

1 version版本
var program = require('commander');
 
program
    .version('0.0.1')
    .parse(process.argv);  
#执行结果:
node index.js -V
0.0.1
复制代码
2 option选项

使用.option()方法定义commander的选项options,示例:.option('-n, --name [items2]', 'name description', 'default value')。

program
  .option('-d, --debug', 'output extra debugging')
  .option('-s, --small', 'small pizza size')
program.parse(process.argv)
if( program.debug ){
    blue('option is debug')
}else if(program.small){
    blue('option is small')
}
复制代码

终端输入

mycli -d
复制代码

终端输出

3 commander自定义指令(重点)

做用:添加命令名称, 示例:.command('add <num>

1 命令名称<必须>:命令后面可跟用 <> 或 [] 包含的参数;命令的最后一个参数能够是可变的,像实例中那样在数组后面加入 ... 标志;在命令后面传入的参数会被传入到 action 的回调函数以及 program.args 数组中。

2 命令描述<可省略>:若是存在,且没有显示调用 action(fn) ,就会启动子命令程序,不然会报错 配置选项<可省略>:可配置noHelp、isDefault等。

使用commander,添加自定义命令

由于咱们作的是脚手架,最基本的功能,建立项目,运行项目(开发环境),打包项目(生产环境),因此咱们添加三个命令,代码以下:

/* mycli create 建立项目 */
program
    .command('create')
    .description('create a project ')
    .action(function(){
        green('👽 👽 👽 '+'欢迎使用mycli,轻松构建react ts项目~🎉🎉🎉')
    })

/* mycli start 运行项目 */
program
.command('start')
 .description('start a project')
 .action(function(){
    green('--------运行项目-------')
 })

/* mycli build 打包项目 */
program
.command('build')
.description('build a project')
.action(function(){
    green('--------构建项目-------')
})

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

效果

mycli create
复制代码

第一步算是完成了。

③ inquirer模块命令行交互

咱们指望像vue-cli或者dva-cli再或者是taro-cli同样,实现和终端的交互功能。这就须要另一个 nodejs模块 inquirerInquirer.js提供用户界面和查询会话。

上手:

var inquirer = require('inquirer');
inquirer
  .prompt([
    /* 把你的问题传过来 */
  ])
  .then(answers => {
    /* 反馈用户内容 */
  })
  .catch(error => {
    /* 出现错误 */
  });
复制代码

因为咱们作的是react脚手架,因此咱们和用户交互问题设定为,是否建立新的项目?(是/否) -> 请输入项目名称?(文本输入) -> 请输入做者?(文本输入) -> 请选择公共管理状态?(单选) mobxredux。上述prompt第一个参数须要对这些问题作基础配置。咱们的 question 配置大体是这样

const question = [
   {
        name:'conf',              /* key */
        type:'confirm',           /* 确认 */
        message:'是否建立新的项目?' /* 提示 */
    },{
        name:'name',
        message:'请输入项目名称?',
        when: res => Boolean(res.conf) /* 是否进行 */
    },{
        name:'author',
        message:'请输入做者?',
        when: res => Boolean(res.conf)
    },{
        type: 'list',            /* 选择框 */
        message: '请选择公共管理状态?',
        name: 'state',
        choices: ['mobx','redux'], /* 选项*/
        filter: function(val) {    /* 过滤 */
          return val.toLowerCase()
        },
        when: res => Boolean(res.conf)
    }
]

复制代码

而后咱们在 command('create') 回调 action()里面继续加上以下代码。

program
    .command('create')
    .description('create a project ')
    .action(function(){
        green('👽 👽 👽 '+'欢迎使用mycli,轻松构建react ts项目~🎉🎉🎉')
        inquirer.prompt(question).then(answer=>{
            console.log('answer=', answer )
        })
    })
复制代码

运行

mycli create 
复制代码

效果以下

接下来咱们要作的是,根据用户提供的信息copy项目文件,copy文件有两种方案,第一种项目模版存在脚手架中,第二种就是向github这种远程拉取项目模版,咱们在这里用的是第一种方案。咱们在脚手架项目中新建template文件夹。放入react-typescript模版。接下来要作的是就是复制整个template项目模版了。

2 深拷贝文件

因为咱们的template项目模版,有多是深层次的 文件夹 -> 文件 结构,咱们须要深复制项目文件和文件夹。因此须要node中原生模块fs模块来助阵。fs大部分api是异步I/O操做,因此须要一些小技巧来处理这些异步操做,咱们稍后会讲到。

1 准备工做: 理解 异步I/O 和 fs模块

笔者看过一些朴灵《深刻浅出nodejs》,里面有一段关于异步I/O描述。

const fs = require('fs')
fs.readFile('/path',()=>{
    console.log('读取文件完成')
})
console.log('发起读取文件')
复制代码

'发起读取文件'是在'读取文件完成'以前输出的,说明用readFile读取文件过程是异步的,这样的意义在于,在node中,咱们能够在语言层面很天然地进行并行的I/O操做。每一个调用之间无须等待以前的I/O调用结束,在编程模型上能够极大提高效率。回到咱们的脚手架项目上来,咱们须要一次性大规模读取模板文件,复制模版文件,也就是会操做不少上述所说的异步I/O操做。

咱们须要nodejsfs模块,实现拷贝整个项目功能。相信对于使用过nodejs开发者来讲,fs模块并不陌生,基本上涉及到文件操做的功能都有用到,因为篇幅的缘由,这里就不一一讲了,感兴趣的同窗能够看看 nodejs中文文档-fs模块基础教程

2 递归复制项目文件

实现思路

思路:

① 选择项目模版 :首先解析在第一步inquirer交互模块下用户选择的项目配置,咱们项目有可能有多套模版。由于好比上述选择状态管理mobx或者是redux,再好比说是选择js项目,或者是ts项目,项目的架构和配置都是不一样的,一套模版知足不了全部状况。咱们在demo中,就用了一种模版,就是最多见的react ts项目模版,这里指的就是在template文件下的项目模版。

② 修改配置:对于咱们在inquirer阶段,提供的配置项,好比项目名称,做者等等,须要咱们对项目模版单独处理,修改配置项。这些信息通常都存在package.json中。

③ 复制模版生成项目: 选择好了项目模版,首先咱们遍历整个template文件夹下面全部文件,判断子文件文件类型,若是是文件就直接复制文件,若是是文件夹,建立文件夹,而后递归遍历文件夹下子文件,重复以上的操做。直到全部的文件所有复制完成。

④ 通知主程序执行下一步操做。

咱们在mycli项目src文件夹下面建立create.js专门用于建立项目。废话很少说,直接上代码。

核心代码

const create = require('../src/create')

program
    .command('create')
    .description('create a project ')
    .action(function(){
        green('👽 👽 👽 '+'欢迎使用mycli,轻松构建react ts项目~🎉🎉🎉')
        /* 和开发者交互,获取开发项目信息 */
        inquirer.prompt(question).then(answer=>{
           if(answer.conf){
              /* 建立文件 */
              create(answer)
           }
        })
    })

复制代码

接下来就是第一阶段核心:

第一步:选择模版

create方法

module.exports = function(res){
    /* 建立文件 */
    utils.green('------开始构建-------')
    /* 找到template文件夹下的模版项目 */
    const sourcePath = __dirname.slice(0,-3)+'template'
    utils.blue('当前路径:'+ process.cwd())
    /* 修改package.json*/
    revisePackageJson( res ,sourcePath ).then(()=>{
        copy( sourcePath , process.cwd() ,npm() )
    })
}
复制代码

在这里咱们要弄明白两个路径的意义:

__dirname:Node.js中,__dirname老是指向被执行 js 文件的绝对路径,因此当你在 /d1/d2/mycli.js文件中写了__dirname, 它的值就是/d1/d2

process.cwd() : process.cwd() 方法会返回 Node.js 进程的当前工做目录。

第一步实际很简单,选择好咱们要复制文件夹的路径,而后根据用户信息进行修改package.json

第二步:修改配置

模版项目中的package.json,咱们这里简单的作一个替换,将 demoNamedemoAuthor 替换成用户输入的项目名称和项目做者。

{
  "name": "demoName",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "mycli start",
    "build": "mycli build"
  },
  "author": "demoAuthor",
  "license": "ISC",
  "dependencies": {
    "@types/react": "^16.9.25",
    "react": "^16.13.1",
    "react-dom": "^16.13.1",
    "react-router": "^5.1.2",
    "react-router-dom": "^5.1.2",
    //...更多内容
  },
}
复制代码

revisePackageJson修改package.json

function revisePackageJson(res,sourcePath){
    return new Promise((resolve)=>{
      /* 读取文件 */
        fs.readFile(sourcePath+'/package.json',(err,data)=>{
            if(err) throw err
            const { author , name  } = res
            let json = data.toString()
            /* 替换模版 */
            json = json.replace(/demoName/g,name.trim())
            json = json.replace(/demoAuthor/g,author.trim())
            const path = process.cwd()+ '/package.json'
            /* 写入文件 */
            fs.writeFile(path, new Buffer(json) ,()=>{
                utils.green( '建立文件:'+ path )
                resolve()
            })
        })
    })
}
复制代码

效果如上所示,这一步实际流程很简单,就是读取template中的package.json文件,而后根据模版替换,接下来从新在目标目录中生成package.json。接下来revisePackageJson返回的promise中进行真正的复制文件流程。

第三步:复制文件
let fileCount = 0  /* 文件数量 */
let dirCount = 0   /* 文件夹数量 */
let flat = 0       /* readir数量 */
/** * * @param {*} sourcePath //template资源路径 * @param {*} currentPath //当前项目路径 * @param {*} cb //项目复制完成回调函数 */
function copy (sourcePath,currentPath,cb){
    flat++
    /* 读取文件夹下面的文件 */
    fs.readdir(sourcePath,(err,paths)=>{
        flat--
        if(err){
            throw err
        }
        paths.forEach(path=>{
            if(path !== '.git' && path !=='package.json' ) fileCount++
            const  newSoucePath = sourcePath + '/' + path
            const  newCurrentPath = currentPath + '/' + path
            /* 判断文件信息 */
            fs.stat(newSoucePath,(err,stat)=>{
                if(err){
                    throw err
                }
                /* 判断是文件,且不是 package.json */
                if(stat.isFile() && path !=='package.json' ){
                    /* 建立读写流 */
                    const readSteam = fs.createReadStream(newSoucePath)
                    const writeSteam = fs.createWriteStream(newCurrentPath)
                    readSteam.pipe(writeSteam)
                    color.green( '建立文件:'+ newCurrentPath  )
                    fileCount--
                    completeControl(cb)
                /* 判断是文件夹,对文件夹单独进行 dirExist 操做 */    
                }else if(stat.isDirectory()){
                    if(path!=='.git' && path !=='package.json' ){
                        dirCount++
                        dirExist( newSoucePath , newCurrentPath ,copy,cb)
                    }
                }
            })
        })
    })
}

/** * * @param {*} sourcePath //template资源路径 * @param {*} currentPath //当前项目路径 * @param {*} copyCallback // 上面的 copy 函数 * @param {*} cb //项目复制完成回调函数 */
function dirExist(sourcePath,currentPath,copyCallback,cb){
    fs.exists(currentPath,(ext=>{
        if(ext){
            /* 递归调用copy函数 */
            copyCallback( sourcePath , currentPath,cb)
        }else {
            fs.mkdir(currentPath,()=>{
                fileCount--
                dirCount--
                copyCallback( sourcePath , currentPath,cb)
                color.yellow('建立文件夹:'+ currentPath )
                completeControl(cb)
            })
        }
    }))
}

复制代码

这一步的流程大体是这样的,首先用 fs.readdir读取template文件夹下面的文件,而后经过 fs.stat读取文件信息,判断文件的类型,若是当前文件类型是文件类型,那么经过读写流fs.createReadStreamfs.createWriteStream建立文件;若是当前文件类型是文件夹类型,判断文件夹是否存在,若是当前文件夹存在,递归调用copy复制文件夹下面的文件,若是不存在,那么从新新建文件夹,而后执行递归调用。这里有一点注意的是,因为咱们对package.json单独处理,因此这里的一切文件操做应该排除package.json。由于咱们要在整个项目文件所有复制后,进行自动下载依赖等后续操做。

小技巧:三变量计数法控制异步I/O操做

上面的内容讲到了fs模块基本都是异步I/O操做,并且咱们的复制文件是深层次递归调用,这就有一个问题,如何才可以判断全部的文件都已经复制完成呢 ,对于这种层次和数量都是未知的文件结构,很难经过promise等异步解决方案来处理。这里咱们没有引入第三方异步流程库,而是巧妙的运用变量计数法来判断是否全部文件均以复制完毕。

变量一flat: 每一次copy函数调用,会执行异步fs.readdir读取文件夹下面的全部文件,咱们用 flat++记录 readdir数量, 每次readdir完成执行flat--

变量二fileCount: 每一次文件(可能文件或者文件夹)的遍历,咱们用fileCount++来记录,当文件建立完成或者文件夹建立完成,执行 fileCount--

变量三dirCount: 每一次判断文件夹的操做,咱们用 dirCount++来记录,当新的文件夹被建立完成,执行 dirCount--


function completeControl(cb){
    /* 三变量均为0,异步I/O执行完毕。 */
    if(fileCount === 0 && dirCount ===0 && flat===0){
        color.green('------构建完成-------')
        if(cb && !isInstall ){
            isInstall = true
            color.blue('-----开始install-----')
            cb(()=>{
                color.blue('-----完成install-----')
                /* 判断是否存在webpack */
                runProject()
            })
        }
    }
}

复制代码

咱们在每次建立文件或文件夹事件执行以后,都会调用completeControl方法,经过判断flat,fileCount,dirCount三个变量均为0,就能判断出整个复制流程,执行完毕,并做出下一步操做。

效果

建立项目阶段完毕

三 构建,集成项目阶段

第二阶段咱们主要完成的功能有如下两个方面:

第一部分: 上述咱们复制了整个项目,接下来须要下载依赖运行项目

第二部分: 咱们只是完成了 mycli create 建立项目流程,对于 mycli start 运行项目 ,和 mycli build 打包编译项目,尚未弄。接下来咱们慢慢道来。

1 解析命令,自动运行命令行。

以前咱们介绍了,经过修改bin,借助commander模块来经过输入终端命令行,来执行node文件,来对应启动咱们的程序。接下来咱们要作的是经过nodejs代码,来执行对应的终端命令。这个功能的背景是,咱们须要在复制整个项目目录以后,来自动下载依赖npm, install,启动项目npm start

首先咱们在mycli脚手架项目的src文件夹下,新建npm.js,用来处理下载依赖,启动项目操做。

which模块助力找到npm

像unixwhich实用程序同样。在PATH环境变量中查找指定可执行文件的第一个实例。不缓存结果,所以hash -rPATH更改时不须要。也就是说咱们能够找到npm实例,经过代码层面控制npm作某些事。

例子🌰🌰🌰:

var which = require('which')
 
//异步用法
which('node', function (er, resolvedPath) {
  // 若是在PATH上找不到“节点”,则返回er
  // 若是找到,则返回exec的绝对路径
})
//同步用法
const resolved = which.sync('node')
复制代码

在npm.js下

const which = require('which')
/* 找到npm */
function findNpm() {
  var npms = process.platform === 'win32' ? ['npm.cmd'] : ['npm']
  for (var i = 0; i < npms.length; i++) {
    try {
      which.sync(npms[i])
      console.log('use npm: ' + npms[i])
      return npms[i]
    } catch (e) {
    }
  }
  throw new Error('please install npm')
}
复制代码

② child_process.spawn运行终端命令

在上面咱们成功找到npm以后,须要用 child_process.spawn运行当前命令。

child_process.spawn(command[, args][, options])

command <string> 要运行的命令。 args <string[]> 字符串参数列表。 options <Object> 配置参数。

/** * * @param {*} cmd * @param {*} args * @param {*} fn */
/* 运行终端命令 */ 
function runCmd(cmd, args, fn) {
  args = args || []
  var runner = require('child_process').spawn(cmd, args, {
    stdio: 'inherit'
  })
  runner.on('close', function (code) {
    if (fn) {
      fn(code)
    }
  })
}

复制代码

③编写npm方法

接下来咱们①②步骤的内容整合在一块儿,把整个npm.js npm方法暴露出去.

/** * * @param {*} installArg 执行命令 命令行组成的数组,默认为 install */
module.exports = function (installArg = [ 'install' ]) {
  /* 经过第一步,闭包保存npm */  
  const npm = findNpm()
  return function (done){
    /* 执行命令 */  
    runCmd(which.sync(npm),installArg, function () {
        /* 执行成功回调 */
        done && done()
     })
  }
}
复制代码

使用例子🌰🌰

const npm = require('./npm')

/* 执行 npm install */
const install = npm()
install()

/* 执行 npm start */
const start = npm(['start]) start() 复制代码

④ 完成自动项目安装,项目启动

咱们在上一步复制项目中,回调函数cb究竟是什么? 相信细心的同窗已经发现了。

const npm = require('./npm')
 copy( sourcePath , process.cwd() ,npm() )
复制代码

cb 函数就是执行npm install 的方法。

咱们接着上述的复制成功后,启动项目来说。在三变量判断项目建立成功以后,咱们开始执行安装项目.

function completeControl(cb){
    if(fileCount === 0 && dirCount ===0 && flat===0){
        color.green('------构建完成-------')
        if(cb && !isInstall ){
            isInstall = true
            color.blue('-----开始install-----')
            /* 下载项目 */
            cb(()=>{
                color.blue('-----完成install-----')
                runProject()
            })
        }
    }
}
复制代码

咱们在安装依赖成功的回调函数中,继续调用runProject启动项目。

function runProject(){
    try{
        /* 继续调用 npm 执行,npm start 命令 */
        const start = npm([ 'start' ])
        start()
    }catch(e){
       color.red('自动启动失败,请手动npm start 启动项目')
    }
} 
复制代码

效果:因为安装依赖时间过长,运行项目阶段没有在视频里展现

runProject代码很简单,继续调用 npm, 执行 npm start 命令。

到此为止,咱们实现了经过 mycli create 建立项目安装依赖运行项目全流程,里面还有集成webpack, 进程通讯等细节,咱们立刻慢慢道来。

2 建立子进程,进程通讯

咱们既然搞定了mycli create细节和实现。接下来咱们须要实现mycli startmycli build 两个功能。

① 双进程解决方案

咱们打算用webpack做为脚手架的构建工具。那么咱们须要mycli主进程,建立一个子进程来管理webpack,合并webpack配置项,运行webpack-dev-serve等,这里注意的是,咱们的主进程是在mycli全局脚手架项目中,而咱们的子进程要创建在咱们本地经过mycli create建立的react新项目node_modules中,因此咱们写了一个脚手架的plugin用来一方面创建和mycli进程通讯,另外一方面管理咱们的react项目的配置,操控webpack

为了方便你们了解,我画了一个流程图。

mycli-react-webpack-plugin在建立项目中package.json中,咱们在安装依赖的过程当中,已经安装在了新建项目的node_modules中。

② mycli start 和 mycli build

第一步:完善 mycli startmycli build

接下来咱们在mycli脚手架项目src文件夹下面建立start.js为了和上述的plugin创建起进程通讯。由于不管是执行mycli start或者是 mycli build都是须要操纵webpack因此咱们写在了一块儿了。

咱们继续在mycli.js中完善 mycli startmycli build两个指令。

const start = require('../src/start')
/* mycli start 运行项目 */
program
.command('start')
 .description('start a project')
 .action(function(){
    green('--------运行项目-------')
    /* 运行项目 */
     start('start').then(()=>{
		green('-------✅ ✅运行完成-------')
	})
 })

/* mycli build 打包项目 */
program
.command('build')
.description('build a project')
.action(function(){
    green('--------构建项目-------')
    /* 打包项目 */
    start('build').then(()=>{
		green('-------✅ ✅构建完成-------')
	})
})

复制代码

第二步:start.js 进程通讯

child_process.fork 介绍

modulePath:子进程运行的模块。

参数说明:(重复的参数说明就不在这里列举)

execPath: 用来建立子进程的可执行文件,默认是/usr/local/bin/node。也就是说,你可经过execPath来指定具体的node可执行文件路径。(好比多个node版本) execArgv:: 传给可执行文件的字符串参数列表。默认是 process.execArgv,跟父进程保持一致。 silent: 默认是false,即子进程的stdio从父进程继承。若是是true,则直接pipe向子进程的child.stdin、child.stdout等。 stdio: 若是声明了stdio,则会覆盖silent选项的设置。

运行子程序

咱们在start.js中启动子进程和上述的mycli-react-webpack-plugin创建起通讯。接下来就是介绍start.js

start.js

'use strict';
/* 启动项目 */
const child_process = require('child_process')
const chalk = require('chalk')
const fs = require('fs')
/* 找到mycli-react-webpack-plugin的路径*/
const currentPath = process.cwd()+'/node_modules/mycli-react-webpack-plugin'

/** * * @param {*} type type = start 本地启动项目 type = build 线上打包项目 */
module.exports = (type) => {
    return new Promise((resolve,reject)=>{
        /* 判断 mycli-react-webpack-plugin 是否存在 */
        fs.exists(currentPath,(ext)=>{
            if(ext){ /* 存在 启动子进程 */
              const children = child_process.fork(currentPath + '/index.js' )
              /* 监听子进程信息 */
              children.on('message',(message)=>{
                  const msg = JSON.parse( message )
                  if(msg.type ==='end'){
                      /* 关闭子进程 */
                      children.kill()
                      resolve()
                  }else if(msg.type === 'error'){
                       /* 关闭子进程 */
                      children.kill()
                      reject()
                  }
              })
              /* 发送cwd路径 和 操做类型 start 仍是 build */
              children.send(JSON.stringify({
                  cwdPath:process.cwd(),
                  type: type || 'build'
              }))
            }else{ /* 不存在,抛出警告,下载 */
               console.log( chalk.red('mycli-react-webpack-plugin does not exist , please install mycli-react-webpack-plugin')   )
            }
        })
    })
}
复制代码

这一步实际很简单,大体分为二步:

1 判断 mycli-react-webpack-plugin 是否存在,若是存在启动 mycli-react-webpack-plugin下的index.js为子进程。若是不存在,抛出警告下载plugin

2 绑定子进程事件message,向子进程发送指令,是启动项目仍是构建项目

③ mycli-react-webpack-plugin

接下来作的事就是让mycli-react-webpack-plugin 完成项目配置项目构建流程。

1 项目结构

mycli-react-webpack-plugin插件项目文件结构

项目目录大体是如上的样子,config文件下,是不一样构建环境的基础配置文件,在项目构建过程当中,会读取建立新项目的mycli.config.js在生产环境和开发环境的配置项,而后合并配置项。

咱们的新建立项目的mycli.config.js

2 入口文件

const RunningWebpack = require('./lib/run')

/** * 建立一个运行程序,在webpack的不一样环境下运行配置文件 */

/* 启动 RunningWebpack 实例 */
const runner = new RunningWebpack()

process.on('message',message=>{
   const msg = JSON.parse( message )
   if(msg.type && msg.cwdPath ){
     runner.listen(msg).then(
          ()=>{
             /* 构建完成 ,通知主进程 ,结束子进程 */ 
             process.send(JSON.stringify({ type:'end' }))
          },(error)=>{
             /* 出现错误 ,通知主进程 ,结束子进程 */     
             process.send(JSON.stringify({ type:'error' , error }))
          }
      )
   }
})
复制代码

咱们这里用RunningWebpack来执行一系列的webpack启动,打包操做。

3 合并配置项,自动启动webpack。

① 基于 EventEmitterRunningWebpack

咱们的 RunningWebpack 基于 nodejsEventEmitter 模块,EventEmitter 能够解决异步I/O,能够在合适的场景触发不一样的webpack命令,好比 start 或者是 build等。

EventEmitter简介

nodejs全部的异步 I/O 操做在完成时都会发送一个事件到事件队列。

Node.js 里面的许多对象都会分发事件:一个 net.Server 对象会在每次有新链接时触发一个事件, 一个 fs.readStream 对象会在文件被打开的时候触发一个事件。 全部这些产生事件的对象都是 events.EventEmitter 的实例。

简单用法

//event.js 文件
var EventEmitter = require('events').EventEmitter; 
var event = new EventEmitter(); 
event.on('some_event', function() { 
    console.log('some_event 事件触发'); 
}); 
setTimeout(function() { 
    event.emit('some_event'); 
}, 1000); 
复制代码

② 合并webpack配置项

上述介绍完用 EventEmitter做为运行webpack的事件模型,接下咱们来分析如下,当运行入口文件的时候。

runner.listen(msg).then
复制代码
const merge = require('./merge')
const webpack = require('webpack')
const runMergeGetConfig = require('../config/webpack.base')
   /** * 接受不一样的webpack状态,合并 */
    listen({ type,cwdPath }){
       this.path = cwdPath
       this.type = type
       /* 合并配置项,获得新的webpack配置项 */
       this.config = merge.call(this,runMergeGetConfig( cwdPath )(type))
       return new Promise((resolve,reject)=>{
           this.emit('running',type)
           this.once('error',reject)
           this.once('end',resolve)
       })
    }
复制代码

listen入参参数有两个,type是主线程的传递过来的webpack命令,分为startbuild,cwdPath是咱们输入终端命令行的绝对路径,接下来咱们要作的是读取新建立项目的mycli.config.js。而后和咱们的默认配置进行合并操做。

runMergeGetConfig

runMergeGetConfig 能够根据咱们传递的环境(start or build)获得对应的webpack基础配置。咱们来一块儿看看runMergeGetConfig 作了什么。

const merge = require('webpack-merge')
module.exports = function(path){
  return type => {
    if (type==='start') {
      return merge(Appconfig(path), devConfig(path))
    } else {
      return merge(Appconfig(path), proConfig)
    }
  }
}
复制代码

runMergeGetConfig 很简单就是将 base基础配置,和 dev或者pro环境进行合并获得脚手架的基本配置,而后再和mycli.config.js文件下的自定义配置项合并,咱们接着看。

merge

咱们接着看 mycli-react-webpack-plugin插件下,lib文件夹下的merge.js

const fs = require('fs')
const merge = require('webpack-merge')


/* 合并配置 */
function configMegre(Pconf,config){
   const {
      dev = Object.create(null),
      pro = Object.create(null),
      base= Object.create(null)
   } = Pconf
   if(this.type === 'start'){
     return merge(config,base,dev)
   }else{
      return merge(config,base,pro)
   }
}

/** * @param {*} config 通过 runMergeGetConfig 获得的脚手架基础配置 */
function megreConfig(config){
   const targetPath = this.path + '/mycli.config.js'
   const isExi = fs.existsSync(targetPath)
   if(isExi){
     /* 获取开发者自定义配置 */ 
      const perconfig = require(targetPath)
      /**/
      const mergeConfigResult = configMegre.call(this,perconfig,config)
      return mergeConfigResult
   }
   /* 返回最终打包的webpack配置项 */
   return config
}

module.exports = megreConfig
复制代码

这一步实际很简单,获取开发者的自定义配置,而后和脚手架的默认配置合并,获得最终的配置。并会返回给咱们的running实例。

③ 自动启动webpack

接下来咱们作的是启动webpack。生产环境比较简单,直接 webpack(config)就能够了。在开发环境中,因为须要webpack-dev-server搭建起服务器,而后挂起项目,因此须要咱们单独处理。首先将开发环境下的config传入webpack中获得compiler,而后启动dev-server服务,compiler 做为参数传入webpack 并监听咱们设置的端口,完成整个流程。

const Server = require('webpack-dev-server/lib/Server')
    const webpack = require('webpack')
    const processOptions = require('webpack-dev-server/lib/utils/processOptions')
    const yargs = require('yargs')
    /* 运行生产环境webpack */
    build(){
        try{
            webpack(this.config,(err)=>{
               if(err){
                   /* 若是发生错误 */
                  this.emit('error')
               }else{
                   /* 结束 */
                  this.emit('end')
               }
            })
        }catch(e){
            this.emit('error')
        }

    }
    /* 运行开发环境webpack */
    start(){
        const _this = this
        processOptions(this.config,yargs.argv,(config,options)=>{
            /* 获得webpack compiler*/
            const compiler = webpack(config)
            /* 建立dev-server服务 */
            const server = new Server(compiler , options )
            /* port 是在webpack.dev.js下的开发环境配置项中 设置的监听端口 */
            server.listen(options.port, options.host, (err) => {
              if (err) {
                _this.emit('error')
                throw err;
              }
            })
        })
    }
复制代码

④效果展现

mycli start

mycli build

完整代码

完整代码

const EventEmitter = require('events').EventEmitter
const Server = require('webpack-dev-server/lib/Server')
const processOptions = require('webpack-dev-server/lib/utils/processOptions')
const yargs = require('yargs')

const merge = require('./merge')
const webpack = require('webpack')
const runMergeGetConfig = require('../config/webpack.base')

/** * 运行不一样环境下的webpack */
class RunningWebpack extends EventEmitter{
    
    /* 绑定 running 方法 */
    constructor(options){
       super()
       this._options = options
       this.path = null
       this.config = null
       this.on('running',(type,...arg)=>{
           this[type] && this[ type ](...arg)
       })
    }
    /* 接受不一样状态下的webpack命令 */
    listen({ type,cwdPath }){
       this.path = cwdPath
       this.type = type
       this.config = merge.call(this,runMergeGetConfig( cwdPath )(type))
       return new Promise((resolve,reject)=>{
           this.emit('running',type)
           this.once('error',reject)
           this.once('end',resolve)
       })
    }
    /* 运行生产环境webpack */
    build(){
        try{
            webpack(this.config,(err)=>{
               if(err){
                  this.emit('error')
               }else{
                  this.emit('end')
               }
            })
        }catch(e){
            this.emit('error')
        }

    }
    /* 运行开发环境webpack */
    start(){
        const _this = this
        processOptions(this.config,yargs.argv,(config,options)=>{
            const compiler = webpack(config)
            const server = new Server(compiler , options )

            server.listen(options.port, options.host, (err) => {
              if (err) {
                _this.emit('error')
                throw err;
              }
            })
        })
    }
}
module.exports = RunningWebpack
复制代码

四 运行项目,实现plugin,自动化收集model阶段

接下来咱们要讲的项目运行阶段,一些附加的配置项,和一块儿其余的操做。

1 实现一个简单的终端加载条的 plugin

咱们写一个webpackplugin作为mycli脚手架的工具,为了方便向开发者展现修改的文件,和一次webpack构建时间,整个插件是在webpack编译阶段完成的。咱们须要简单了解webpack一些知识。

① Compiler 和 Compilation

在开发 Plugin 时最经常使用的两个对象就是 CompilerCompilation ,它们是 PluginWebpack 之间的桥梁。 CompilerCompilation 的含义以下:

Compiler 对象包含了 Webpack 环境全部的的配置信息,包含 options,loaders,plugins 这些信息,这个对象在 Webpack 启动时候被实例化,它是全局惟一的,能够简单地把它理解为 Webpack 实例; Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。当 Webpack 以开发模式运行时,每当检测到一个文件变化,一次新的 Compilation 将被建立。 Compilation 对象也提供了不少事件回调供插件作扩展。经过 Compilation 也能读取到 Compiler 对象。 CompilerCompilation 的区别在于: Compiler 表明了整个 Webpack 从启动到关闭的生命周期,而 Compilation 只是表明了一次新的编译。

Compiler 编译阶段

咱们要理解一次Compiler各个阶段要作的事,才能在特定的阶段用指定的钩子来完成咱们的自定义plugin

1 run

启动一次新的编译

2 watch-run

run 相似,区别在于它是在监听模式下启动的编译,在这个事件中能够获取到是哪些文件发生了变化致使从新启动一次新的编译。

3 compile

该事件是为了告诉插件一次新的编译将要启动,同时会给插件带上 compiler 对象。

4 compilation

Webpack 以开发模式运行时,每当检测到文件变化,一次新的 Compilation 将被建立。一个 Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。Compilation 对象也提供了不少事件回调供插件作扩展。

5 make

一个新的 Compilation 建立完毕,即将从 Entry 开始读取文件,根据文件类型和配置的 Loader 对文件进行编译,编译完后再找出该文件依赖的文件,递归的编译和解析。

6 after-compile

一次 Compilation 执行完成。

7 invalid

当遇到文件不存在、文件编译错误等异常时会触发该事件,该事件不会致使 Webpack 退出。

③ 编写插件

咱们编写的webpack插件,须要在改动时候,打印出当前改动的文件 ,并用进度条展现一次编译的时间。

上代码

const chalk = require('chalk')
var slog = require('single-line-log');

class MycliConsolePlugin {
    
    constructor(options){
       this.options = options
    }
    apply(compiler){
        /* 监听文件改动 */
        compiler.hooks.watchRun.tap('MycliConsolePlugin', (watching) => {
            const changeFiles = watching.watchFileSystem.watcher.mtimes
            for(let file in changeFiles){
                console.log(chalk.green('当前改动文件:'+ file))
            }
        })
        /* 在一次编译建立以前 */
        compiler.hooks.compile.tap('MycliConsolePlugin',()=>{
            this.beginCompile()
        })
        /* 一次 compile 完成 */
        compiler.hooks.done.tap('MycliConsolePlugin',()=>{
            this.timer && clearInterval( this.timer )
            console.log( chalk.yellow(' 编译完成') )
        })
    }
    /* 开始记录编译 */
    beginCompile(){
       const lineSlog = slog.stdout
       let text  = '开始编译:'

       this.timer = setInterval(()=>{
          text +=  '█'
          lineSlog( chalk.green(text))
       },50)
    }
}
module.exports = RuxConsolePlugin
复制代码

使用

插件的使用,由于咱们这个插件是在开发环境下,因此只须要在webpack.dev.js加入上述的MycliConsolePlugin插件。

const webpack = require('webpack')
const MycliConsolePlugin = require('../plugins/mycli-console-pulgin')
const devConfig =(path)=>{
  return  {
    devtool: 'cheap-module-eval-source-map',
    mode: 'development',
    devServer: {
      contentBase: path + '/dist',
      open: true, /* 自动打开浏览器 */
      hot: true,
      historyApiFallback: true,
      publicPath: '/',
      port: 8888, /* 服务器端口 */
      inline: true,
      proxy: {  /* 代理服务器 */
      }    },
    plugins: [
      new webpack.HotModuleReplacementPlugin(),
      new MycliConsolePlugin({
        dec:1
      })
    ]
  }
}
module.exports = devConfig
复制代码

效果

2 require.context实现前端自动化

前端自动化已经脱离 mycli范畴了,可是为了让你们明白前端自动化流程,这里用webpack提供的API 中的require.context为案例。

require.context讲解

require.context(directory, useSubdirectories = true, regExp = /^\.\/.*$/, mode = 'sync');
复制代码

能够给这个函数传入三个参数: ① directory 要搜索的目录, ② useSubdirectories 标记表示是否还搜索其子目录, ③ regExp 匹配文件的正则表达式。

webpack 会在构建中解析代码中的 require.context()

官网示例:

/* (建立出)一个 context,其中文件来自 test 目录,request 以 `.test.js` 结尾。 */
require.context('./test', false, /\.test\.js$/);

/* (建立出)一个 context,其中全部文件都来自父文件夹及其全部子级文件夹,request 以 `.stories.js` 结尾。 */
require.context('../', true, /\.stories\.js$/);

复制代码

实现自动化

咱们接着用mycli建立的项目做为demo,咱们在项目src文件夹下面新建model文件夹,用来自动收集里面的文件。model文件下,有三个文件 demo.ts , demo1.ts ,demo2.ts ,咱们接下来作的是自动收集文件下的数据。

项目目录

demo.ts

const a = 'demo'

export default a
复制代码

· demo1.ts

const b = 'demo1'

export default b
复制代码

demo2.ts

const b = 'demo2'

export default b
复制代码

探索 require.context

const file  = require.context('./model',false,/\.tsx?|jsx?$/)
console.log(file)

复制代码

打印file ,咱们发现webpack的方法。接下来咱们获取文件名组成的数组。

const file  = require.context('./model',false,/\.tsx?|jsx?$/)
console.log(file.keys())
复制代码

解析来咱们自动收集文件下的a , b ,c 变量。

/* 用来收集文件 */
const model ={} 
const file  = require.context('./model',false,/\.tsx?|jsx?$/)

/* 遍历文件 */
file.keys().map(item=>{
    /* 收集数据 */
    model[item] = file(item).default
})

console.log(model)
复制代码

到这里咱们实现了自动收集流程。若是深层次递归收集,咱们能够将 require.context 第二个参数设置为true

require.context('./model',true,/\.tsx?|jsx?$/)
复制代码

项目目录

demo3.ts

const d = 'demo3'

export default d
复制代码

打印完美递归收集了子文件下的model

五 总结

技术汇总

整个自定义脚手架包含的技术有;

源码地址

rux-cli脚手架

rux-react-webpack-plugin

感兴趣的同窗能够本身去尝试写一个属于本身的脚手架,过程当中会学会不少知识。

送人玫瑰,手留余香,阅读的朋友能够给笔者点赞,关注一波 。 陆续更新前端文章。

感受有用的朋友能够关注笔者公众号 前端Sharing 持续更新好文章。

参考文档

1. Commander.js 中文文档(cli必备)

[2.深刻浅出webpack]

3.webpack中文文档