「前端工程化」從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中文文檔

相關文章
相關標籤/搜索