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={}便可。
其实,前端构建工具背后的原理,远比想像中更简单。