ES2017+,你再也不須要糾結於複雜的構建工具技術選型。前端
也再也不須要gulp,grunt,yeoman,metalsmith,fis3。node
以上的這些構建工具,能夠腦海中永遠劃掉。git
100行代碼,你將透視構建工具的本質。github
100行代碼,你將擁有一個現代化、規範、測試驅動、高延展性的前端構建工具。npm
什麼是鏈式操做、中間件機制?json
如何讀取、構建文件樹?gulp
如何實現批量模板渲染、代碼轉譯?數組
如何實現中間件間數據共享。bash
相信學完這一課後,你會發現————這些專業術語,背後的原理實在。。。太簡單了吧!babel
若是想當即體驗它的強大功能,能夠命令行輸入npx mofast example
,將會構建一個mofast-example
文件夾。
進入文件後運行node compile
,便可體驗功能。
順便說一句,npx mofast example
命令行自己,也是用本課的構建工具實現的。——是否是難以想象?
npm i mofast -D
便可在任何項目中使用mofast,替代gulp/grunt/yeoman/metalsmith/fis3進行安裝使用。
本課程github地址爲: github.com/wanthering/… 在學完課程後,你就能夠提交PR,一塊兒維護這個庫,使它的擴展性愈來愈強!
請搭建好如下環境:
或者直接使用npx lunz mofast
而後一路回車。
構建出的文件系統以下
├── .babelrc
├── .editorconfig
├── .eslintrc.js
├── .gitignore
├── README.md
├── circle.yml
├── package.json
├── src
│ └── index.js
├── test
│ └── index.spec.js
└── yarn.lock
複製代碼
構建工具,都須要進行文件系統的操做。
在測試時,經常污染本地的文件系統,形成一些重要文件的意外丟失和修改。
因此,咱們每每會爲測試作一個「沙盒環境」
在package.json同級目錄下,輸入命令
mkdir __mocks__ && touch __mocks__/fs.js
yarn add memfs -D
yarn add fs-extra
複製代碼
建立__mocks__/fs.js
文件後,寫入:
const { fs } = require('memfs')
module.exports = fs
複製代碼
而後在測試文件index.spec.js
的第一行寫下:
jest.mock('fs')
import fs from 'fs-extra'
複製代碼
解釋一下: __mocks__中的文件將自動加載到測試的mock環境中,而經過jest.mock('fs'),將覆蓋掉原來的fs操做,至關於整個測試都在沙盒環境中運行。
src/index.js
import { EventEmitter } from 'events'
class Mofast extends EventEmitter {
constructor () {
super()
this.files = {}
this.meta = {}
}
source (patterns, { baseDir = '.', dotFiles = true } = {}) {
// TODO: parse the source files
}
async dest (dest, { baseDir = '.', clean = false } = {}) {
// TODO: conduct to dest
}
}
const mofast = () => new Mofast()
export default mofast
複製代碼
使用EventEmitter做爲父類,是由於須要emit事件,以監控文件流的動做。
使用this.files保存文件鏈。
使用this.meta 保存數據。
在裏面寫入了source方法,和dest方法。使用方法以下:
test/index.spec.js
import fs from 'fs-extra'
import mofast from '../src'
import path from "path"
jest.mock('fs')
// 準備原始模板文件
const templateDir = path.join(__dirname, 'fixture/templates')
fs.ensureDirSync(templateDir)
fs.writeFileSync(path.join(templateDir, 'add.js'), `const add = (a, b) => a + b`)
test('main', async ()=>{
await mofast()
.source('**', {baseDir: templateDir})
.dest('./output', {baseDir: __dirname})
const fileOutput = fs.readFileSync(path.resolve(__dirname, 'output/tmp.js'), 'utf-8')
expect(fileOutput).toBe(`const add = (a, b) => a + b`)
})
複製代碼
如今,咱們以跑通這個test爲目標,完成Mofast類的初步編寫。
將參數中的patterns, baseDir, dotFiles掛載到this上,並返回this, 以便於鏈式操做便可。
dest函數,是一個異步函數。
它完成兩個操做:
能夠這兩個操做分別獨立成兩個異步函數: process(),和writeFileTree()
注意,由於是批量處理,須要採用Promise.all()同時執行。
假如/fixture/template/add.js
文件的內容爲const add = (a, b) => a + b
處理後的this.file對象示意:
{
'add.js': {
content: 'const add = (a, b) => a + b',
stats: {...},
path: '/fixture/template/add.js'
}
}
複製代碼
遍歷this.file,使用fs.ensureDir保證文件夾存在後, 將this.file[filename].content寫入絕對路徑。
import { EventEmitter } from 'events'
import glob from 'fast-glob'
import path from 'path'
import fs from 'fs-extra'
class Mofast extends EventEmitter {
constructor () {
super()
this.files = {}
this.meta = {}
}
/**
* 將參數掛載到this上
* @param patterns glob匹配模式
* @param baseDir 源文件根目錄
* @param dotFiles 是否識別隱藏文件
* @returns this 返回this,以便鏈式操做
*/
source (patterns, { baseDir = '.', dotFiles = true } = {}) {
//
this.sourcePatterns = patterns
this.baseDir = baseDir
this.dotFiles = dotFiles
return this
}
/**
* 將baseDir中的文件的內容、狀態和絕對路徑,掛載到this.files上
*/
async process () {
const allStats = await glob(this.sourcePatterns, {
cwd: this.baseDir,
dot: this.dotFiles,
stats: true
})
this.files = {}
await Promise.all(
allStats.map(stats => {
const absolutePath = path.resolve(this.baseDir, stats.path)
return fs.readFile(absolutePath).then(contents => {
this.files[stats.path] = { contents, stats, path: absolutePath }
})
})
)
return this
}
/**
* 將this.files寫入目標文件夾
* @param destPath 目標路徑
*/
async writeFileTree(destPath){
await Promise.all(
Object.keys(this.files).map(filename => {
const { contents } = this.files[filename]
const target = path.join(destPath, filename)
this.emit('write', filename, target)
return fs.ensureDir(path.dirname(target))
.then(() => fs.writeFile(target, contents))
})
)
}
/**
*
* @param dest 目標文件夾
* @param baseDir 目標文件根目錄
* @param clean 是否清空目標文件夾
*/
async dest (dest, { baseDir = '.', clean = false } = {}) {
const destPath = path.resolve(baseDir, dest)
await this.process()
if(clean){
await fs.remove(destPath)
}
await this.writeFileTree(destPath)
return this
}
}
const mofast = () => new Mofast()
export default mofast
複製代碼
執行yarn test
,測試跑通。
若是說咱們正在編寫的類,是一把槍。
那麼中間件,就是一顆顆子彈。
你須要一顆顆將子彈推入槍中,而後一次所有打出去。
寫一個測試用例,將add.js文件中的const add = (a, b) => a + b
修改成var add = (a, b) => a + b
test/index.spec.js
test('middleware', async () => {
const stream = mofast()
.source('**', { baseDir: templateDir })
.use(({ files }) => {
const contents = files['add.js'].contents.toString()
files['add.js'].contents = Buffer.from(contents.replace(`const`, `var`))
})
await stream.process()
expect(stream.fileContents('add.js')).toMatch(`var add = (a, b) => a + b`)
})
複製代碼
好,如今來實現middleware
在constructor裏面初始化constructor數組
src/index.js > constructor
constructor () {
super()
this.files = {}
this.middlewares = []
}
複製代碼
建立一個use函數,用來將中間件推入數組,就像一顆顆子彈推入彈夾。
src/index.js > constructor
use(middleware){
this.middlewares.push(middleware)
return this
}
複製代碼
在process異步函數中,處理完文件以後,當即執行中間件。 注意,中間件的參數應該是this,這樣就能夠取到掛載在主類上面的this.files
、this.baseDir
等參數了。
src/index.js > process
async process () {
const allStats = await glob(this.sourcePatterns, {
cwd: this.baseDir,
dot: this.dotFiles,
stats: true
})
this.files = {}
await Promise.all(
allStats.map(stats => {
const absolutePath = path.resolve(this.baseDir, stats.path)
return fs.readFile(absolutePath).then(contents => {
this.files[stats.path] = { contents, stats, path: absolutePath }
})
})
)
for(let middleware of this.middlewares){
await middleware(this)
}
return this
}
複製代碼
最後,咱們新寫了一個方法fileContents,用於讀取文件對象上面的內容,以便進行測試
fileContents(relativePath){
return this.files[relativePath].contents.toString()
}
複製代碼
執行一下yarn test
,測試經過。
既然已經有了中間件機制.
咱們能夠封裝一些經常使用的中間件,例如ejs / handlebars模板引擎
使用前的文件內容是: my name is <%= name %>
或my name is {{ name }}
輸入{name: 'jack}
得出結果my name is jack
以及babel轉譯:
使用前文件內容是: const add = (a, b) => a + b
轉譯後獲得var add = function(a, b){ return a + b}
好, 咱們來書寫測試用例:
// 準備原始模板文件
fs.writeFileSync(path.join(templateDir, 'ejstmp.txt'), `my name is <%= name %>`)
fs.writeFileSync(path.join(templateDir, 'hbtmp.hbs'), `my name is {{name}}`)
test('ejs engine', async () => {
await mofast()
.source('**', { baseDir: templateDir })
.engine('ejs', { name: 'jack' }, '*.txt')
.dest('./output', { baseDir: __dirname })
const fileOutput = fs.readFileSync(path.resolve(__dirname, 'output/ejstmp.txt'), 'utf-8')
expect(fileOutput).toBe(`my name is jack`)
})
test('handlebars engine', async () => {
await mofast()
.source('**', { baseDir: templateDir })
.engine('handlebars', { name: 'jack' }, '*.hbs')
.dest('./output', { baseDir: __dirname })
const fileOutput = fs.readFileSync(path.resolve(__dirname, 'output/hbtmp.hbs'), 'utf-8')
expect(fileOutput).toBe(`my name is jack`)
})
test('babel', async () => {
await mofast()
.source('**', { baseDir: templateDir })
.babel()
.dest('./output', { baseDir: __dirname })
const fileOutput = fs.readFileSync(path.resolve(__dirname, 'output/add.js'), 'utf-8')
expect(fileOutput).toBe(`var add = function (a, b) { return a + b; }`)
})
複製代碼
engine()
有三個參數
babel()
有一個參數
經過nodejs
的assert
,確保type
爲ejs
和handlebars
之一
經過jstransformer
+jstransformer-ejs
和jstransformer-handlebars
判斷locals的類型,若是是函數,則傳入執行上下文,使得能夠訪問files和meta等值。 若是是對象,則把meta值合併進去。
使用minimatch
,匹配文件名是否符合給定的pattern
,若是符合,則進行處理。 若是不輸入pattern
,則處理所有文件。
創立一箇中間件,在中間件中遍歷files,將單個文件的contents取出來進行處理後,更新到原來位置。
將中間件推入數組
經過nodejs
的assert
,確保type
爲ejs
和handlebars
之一
經過buble
包(簡化版的bable),進行轉換代碼轉換。
使用minimatch
,匹配文件名是否符合給定的pattern
,若是符合,則進行處理。 若是不輸入pattern
,則處理全部js
和jsx
文件。
創立一箇中間件,在中間件中遍歷files,將單個文件的contents取出來轉化爲es5代碼後,更新到原來位置。
接下來,安裝依賴
yarn add jstransformer jstransformer-ejs jstransformer-handlebars minimatch buble
複製代碼
並在頭部進行引入
src/index.js
import assert from 'assert'
import transformer from 'jstransformer'
import minimatch from 'minimatch'
import {transform as babelTransform} from 'buble'
複製代碼
補充engine和bable方法
engine (type, locals, pattern) {
const supportedEngines = ['handlebars', 'ejs']
assert(typeof (type) === 'string' && supportedEngines.includes(type), `engine must be value of ${supportedEngines.join(',')}`)
const Transform = transformer(require(`jstransformer-${type}`))
const middleware = context => {
const files = context.files
let templateData
if (typeof locals === 'function') {
templateData = locals(context)
} else if (typeof locals === 'object') {
templateData = { ...locals, ...context.meta }
}
for (let filename in files) {
if (pattern && !minimatch(filename, pattern)) continue
const content = files[filename].contents.toString()
files[filename].contents = Buffer.from(Transform.render(content, templateData).body)
}
}
this.middlewares.push(middleware)
return this
}
babel (pattern) {
pattern = pattern || '*.js?(x)'
const middleware = (context) => {
const files = context.files
for (let filename in files) {
if (pattern && !minimatch(filename, pattern)) continue
const content = files[filename].contents.toString()
files[filename].contents = Buffer.from(babelTransform(content).code)
}
}
this.middlewares.push(middleware)
return this
}
複製代碼
書寫測試用例
test/index.spec.js
test('filter', async () => {
const stream = mofast()
stream.source('**', { baseDir: templateDir })
.filter(filepath => {
return filepath !== 'hbtmp.hbs'
})
await stream.process()
expect(stream.fileList).toContain('add.js')
expect(stream.fileList).not.toContain('hbtmp.hbs')
})
複製代碼
新增了一個fileList方法,能夠從this.files中獲取到所有的文件名數組。
依然,經過注入中間件的方法,建立filter()方法。
src/index.js
filter (fn) {
const middleware = ({files}) => {
for (let filenames in files) {
if (!fn(filenames, files[filenames])) {
delete files[filenames]
}
}
}
this.middlewares.push(middleware)
return this
}
get fileList () {
return Object.keys(this.files).sort()
}
複製代碼
跑一下yarn test
,經過測試
這時,基本上一個小型構建工具的所有功能已經實現了。
這時輸入yarn lint
統一文件格式。
再輸入yarn build
打包文件,這時出現dist/index.js
便是npm使用的文件
在package.json中增長main字段,指向dist/index.js
增長files字段,指示npm包僅包含dist文件夾便可
"main": "dist/index.js",
"files": ["dist"],
複製代碼
而後使用
npm publish
複製代碼
便可將包發佈在npm上。
好了,回答最開始的問題:
什麼是鏈式操做?
答: 返回this
什麼是中間件機制
答:就是將一個個異步函數推入堆棧,最後遍歷執行。
如何讀取、構建文件樹。
答:文件樹,就是key爲文件相對路徑,value爲文件內容等信息的對象this.files。
讀取文件樹,就是取得相對路徑數組後,採用Promise.all批量fs.readFile取文件內容後掛載到this.files上去。
構建文件樹,就是this.files採用Promise.all批量fs.writeFile到目標文件夾。
如何實現模板渲染、代碼轉譯?
答:就是從文件樹上取出文件,ejs.render()或bable.transform()以後放回原處。
如何實現中間件間數據共享?
答:contructor中建立this.meta={}便可。
其實,前端構建工具背後的原理,遠比想像中更簡單。