阅读 6750

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

一 前言

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

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

1 实现效果展示

项目效果

mycli creat 创建项目

mycli start 运行项目

mycli build 打包项目

体验步骤

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

全局下载脚手架rux-cli

windows

npm install rux-cli -g 
复制代码

mac

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

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

rux create 
复制代码

2 设置目标

设置目标,分解目标

我们希望用一条命令行,实现项目创建依赖下载,项目运行依赖收集等众多流程。如果一口气设计整个功能,可能会感到脑袋一片空白,所以我们要学会分解目标。实际纵览整个流程,主要分为 创建文件阶段构建,集成webpack阶段运行项目阶段 。梳理每个阶段我们需要做的事情。

二 创建文件阶段

1 终端命令行交互

① node 修改 bin

我们期望像vue-cli那样 ,通过自定义的命令行vue create,开始创建一个项目,首先能够让程序终端识别我们的自定义指令,我们首先需要修改bin

例子:

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中文文档

文章分类
前端
文章标签