在上一篇文章咱們對 Stream 的特性及其接口進行了介紹,gulp 之因此在性能上好於 grunt,主要是由於有了 Stream 助力來作數據的傳輸和處理。html
那麼咱們不難猜測出,在 gulp 的任務中,gulp.src 接口將匹配到的文件轉化爲可讀(或 Duplex/Transform)流,經過 .pipe 流經各插件進行處理,最終推送給 gulp.dest 所生成的可寫(或 Duplex/Transform)流並生成文件。node
本文將追蹤 gulp(v4.0)的源碼,對上述猜測進行驗證。git
爲了分析源碼,咱們打開 gulp 倉庫下的入口文件 index.js,能夠很直觀地發現,幾個主要的 API 都是直接引用 vinyl-fs 模塊上暴露的接口的:github
var util = require('util'); var Undertaker = require('undertaker'); var vfs = require('vinyl-fs'); var watch = require('glob-watcher'); //略... Gulp.prototype.src = vfs.src; Gulp.prototype.dest = vfs.dest; Gulp.prototype.symlink = vfs.symlink; //略...
所以瞭解 vinyl-fs 模塊的做用,便成爲掌握 gulp 工做原理的關鍵之一。須要留意的是,當前 gulp4.0 所使用的 vinyl-fs 版本是 v2.0.0。正則表達式
vinyl-fs 實際上是在 vinyl 模塊的基礎上作了進一步的封裝,在這裏先對它們作個介紹:算法
一. Vinylshell
Vinyl 能夠看作一個文件描述器,經過它能夠輕鬆構建單個文件的元數據(metadata object)描述對象。依舊是來個例子簡潔明瞭:gulp
//ch2-demom1 var Vinyl = require('vinyl'); var jsFile = new Vinyl({ cwd: '/', base: '/test/', path: '/test/file.js', contents: new Buffer('abc') }); var emptyFile = new Vinyl(); console.dir(jsFile); console.dir(emptyFile);
上述代碼會打印兩個File文件對象:windows
簡而言之,Vinyl 能夠建立一個文件描述對象,經過接口能夠取得該文件所對應的數據(Buffer類型)、cwd路徑、文件名等等:數組
//ch2-demo2 var Vinyl = require('vinyl'); var file = new Vinyl({ cwd: '/', base: '/test/', path: '/test/newFile.txt', contents: new Buffer('abc') }); console.log(file.contents.toString()); console.log('path is: ' + file.path); console.log('basename is: ' + file.basename); console.log('filename without suffix: ' + file.stem); console.log('file extname is: ' + file.extname);
打印結果:
更全面的 API 請參考官方描述文檔,這裏也對 vinyl 的源碼貼上解析註釋:
var path = require('path'); var clone = require('clone'); var cloneStats = require('clone-stats'); var cloneBuffer = require('./lib/cloneBuffer'); var isBuffer = require('./lib/isBuffer'); var isStream = require('./lib/isStream'); var isNull = require('./lib/isNull'); var inspectStream = require('./lib/inspectStream'); var Stream = require('stream'); var replaceExt = require('replace-ext'); //構造函數 function File(file) { if (!file) file = {}; //-------------配置項缺省設置 // history是一個數組,用於記錄 path 的變化 var history = file.path ? [file.path] : file.history; this.history = history || []; this.cwd = file.cwd || process.cwd(); this.base = file.base || this.cwd; // 文件stat,它其實就是 require('fs').Stats 對象 this.stat = file.stat || null; // 文件內容(這裏其實只容許格式爲 stream 或 buffer 的傳入) this.contents = file.contents || null; this._isVinyl = true; } //判斷是否 this.contents 是否 Buffer 類型 File.prototype.isBuffer = function() { //直接用 require('buffer').Buffer.isBuffer(this.contents) 作判斷 return isBuffer(this.contents); }; //判斷是否 this.contents 是否 Stream 類型 File.prototype.isStream = function() { //使用 this.contents instanceof Stream 作判斷 return isStream(this.contents); }; //判斷是否 this.contents 是否 null 類型(例如當file爲文件夾路徑時) File.prototype.isNull = function() { return isNull(this.contents); }; //經過文件 stat 判斷是否爲文件夾 File.prototype.isDirectory = function() { return this.isNull() && this.stat && this.stat.isDirectory(); }; //克隆對象,opt.deep 決定是否深拷貝 File.prototype.clone = function(opt) { if (typeof opt === 'boolean') { opt = { deep: opt, contents: true }; } else if (!opt) { opt = { deep: true, contents: true }; } else { opt.deep = opt.deep === true; opt.contents = opt.contents !== false; } // 先克隆文件的 contents var contents; if (this.isStream()) { //文件內容爲Stream //Stream.PassThrough 接口是 Transform 流的一個簡單實現,將輸入的字節簡單地傳遞給輸出 contents = this.contents.pipe(new Stream.PassThrough()); this.contents = this.contents.pipe(new Stream.PassThrough()); } else if (this.isBuffer()) { //文件內容爲Buffer /** cloneBuffer 裏是經過 * var buf = this.contents; * var out = new Buffer(buf.length); * buf.copy(out); * 的形式來克隆 Buffer **/ contents = opt.contents ? cloneBuffer(this.contents) : this.contents; } //克隆文件實例對象 var file = new File({ cwd: this.cwd, base: this.base, stat: (this.stat ? cloneStats(this.stat) : null), history: this.history.slice(), contents: contents }); // 克隆自定義屬性 Object.keys(this).forEach(function(key) { // ignore built-in fields if (key === '_contents' || key === 'stat' || key === 'history' || key === 'path' || key === 'base' || key === 'cwd') { return; } file[key] = opt.deep ? clone(this[key], true) : this[key]; }, this); return file; }; /** * pipe原型接口定義 * 用於將 file.contents 寫入流(即參數stream)中; * opt.end 用於決定是否關閉 stream */ File.prototype.pipe = function(stream, opt) { if (!opt) opt = {}; if (typeof opt.end === 'undefined') opt.end = true; if (this.isStream()) { return this.contents.pipe(stream, opt); } if (this.isBuffer()) { if (opt.end) { stream.end(this.contents); } else { stream.write(this.contents); } return stream; } // file.contents 爲 Null 的狀況不往stream注入內容 if (opt.end) stream.end(); return stream; }; /** * inspect原型接口定義 * 用於打印出一條與文件內容相關的字符串(經常使用於調試打印) * 該方法可忽略 */ File.prototype.inspect = function() { var inspect = []; // use relative path if possible var filePath = (this.base && this.path) ? this.relative : this.path; if (filePath) { inspect.push('"'+filePath+'"'); } if (this.isBuffer()) { inspect.push(this.contents.inspect()); } if (this.isStream()) { //inspectStream模塊裏有個有趣的寫法——判斷是否純Stream對象,先判斷是否Stream實例, //再判斷 this.contents.constructor.name 是否等於'Stream' inspect.push(inspectStream(this.contents)); } return '<File '+inspect.join(' ')+'>'; }; /** * 靜態方法,用於判斷文件是否Vinyl對象 */ File.isVinyl = function(file) { return file && file._isVinyl === true; }; // 定義原型屬性 .contents 的 get/set 方法 Object.defineProperty(File.prototype, 'contents', { get: function() { return this._contents; }, set: function(val) { //只容許寫入類型爲 Buffer/Stream/Null 的數據,否則報錯 if (!isBuffer(val) && !isStream(val) && !isNull(val)) { throw new Error('File.contents can only be a Buffer, a Stream, or null.'); } this._contents = val; } }); // 定義原型屬性 .relative 的 get/set 方法(該方法幾乎不使用,可忽略) Object.defineProperty(File.prototype, 'relative', { get: function() { if (!this.base) throw new Error('No base specified! Can not get relative.'); if (!this.path) throw new Error('No path specified! Can not get relative.'); //返回 this.path 和 this.base 的相對路徑 return path.relative(this.base, this.path); }, set: function() { //不容許手動設置 throw new Error('File.relative is generated from the base and path attributes. Do not modify it.'); } }); // 定義原型屬性 .dirname 的 get/set 方法,用於獲取/設置指定path文件的文件夾路徑。 // 要求初始化時必須指定 path <或history> Object.defineProperty(File.prototype, 'dirname', { get: function() { if (!this.path) throw new Error('No path specified! Can not get dirname.'); return path.dirname(this.path); }, set: function(dirname) { if (!this.path) throw new Error('No path specified! Can not set dirname.'); this.path = path.join(dirname, path.basename(this.path)); } }); // 定義原型屬性 .basename 的 get/set 方法,用於獲取/設置指定path路徑的最後一部分。 // 要求初始化時必須指定 path <或history> Object.defineProperty(File.prototype, 'basename', { get: function() { if (!this.path) throw new Error('No path specified! Can not get basename.'); return path.basename(this.path); }, set: function(basename) { if (!this.path) throw new Error('No path specified! Can not set basename.'); this.path = path.join(path.dirname(this.path), basename); } }); // 定義原型屬性 .extname 的 get/set 方法,用於獲取/設置指定path的文件擴展名。 // 要求初始化時必須指定 path <或history> Object.defineProperty(File.prototype, 'extname', { get: function() { if (!this.path) throw new Error('No path specified! Can not get extname.'); return path.extname(this.path); }, set: function(extname) { if (!this.path) throw new Error('No path specified! Can not set extname.'); this.path = replaceExt(this.path, extname); } }); // 定義原型屬性 .path 的 get/set 方法,用於獲取/設置指定path。 Object.defineProperty(File.prototype, 'path', { get: function() { //直接從history出棧 return this.history[this.history.length - 1]; }, set: function(path) { if (typeof path !== 'string') throw new Error('path should be string'); // 壓入history棧中 if (path && path !== this.path) { this.history.push(path); } } }); module.exports = File;
二. Vinyl-fs
Vinyl 雖然能夠很方便地來描述一個文件、設置或獲取文件的內容,但還沒能便捷地與文件系統進行接入。
個人意思是,咱們但願可使用通配符的形式來簡單地匹配到咱想要的文件,把它們轉爲能夠處理的 Streams,作一番加工後,再把這些 Streams 轉換爲處理完的文件。
Vinyl-fs 就是實現這種需求的一個 Vinyl 適配器,咱們看看它的用法:
var map = require('map-stream'); var fs = require('vinyl-fs'); var log = function(file, cb) { console.log(file.path); cb(null, file); }; fs.src(['./js/**/*.js', '!./js/vendor/*.js']) .pipe(map(log)) .pipe(fs.dest('./output'));
如上方代碼所示,Vinyl-fs 的 .src 接口能夠匹配一個通配符,將匹配到的文件轉爲 Vinyl Stream,而 .dest 接口又能消費這個 Stream,並生成對應文件。
這裏須要先補充一個概念 —— .src 接口所傳入的「通配符」有個專有術語,叫作 GLOB,咱們先來聊聊 GLOB。
GLOB 能夠理解爲咱們給 gulp.src 等接口傳入的第一個 pattern 參數的形式,例如「./js/**/*.js」,另外百度百科的「glob模式」描述是這樣的:
所謂的 GLOB 模式是指 shell 所使用的簡化了的正則表達式:
⑴ 星號(*)匹配零個或多個任意字符;
⑵ [abc]匹配任何一個列在方括號中的字符(這個例子要麼匹配一個 a,要麼匹配一個 b,要麼匹配一個 c);
⑶ 問號(?)只匹配一個任意字符;
⑷ 若是在方括號中使用短劃線分隔兩個字符,表示全部在這兩個字符範圍內的均可以匹配(好比 [0-9] 表示匹配全部 0 到 9 的數字)。
在 vinyl-fs 中,是使用 glob-stream <v5.0.0>經過算法(minimatch)來解析 GLOB 的,它會拿符合上述 GLOB 模式規範的 pattern 參數去匹配相應的文件,:
var gs = require('glob-stream'); var stream = gs.create('./files/**/*.coffee', {options}); stream.on('data', function(file){ // file has path, base, and cwd attrs });
而 glob-stream 又是藉助了 node-glob 來匹配文件列表的:
//ch2-demo3 var Glob = require("glob").Glob; var path = require('path'); var pattern = path.join(__dirname, '/*.txt'); var globber = new Glob(pattern, function(err, matches){ console.log(matches) }); globber.on('match', function(filename) { console.log('matches file: ' + filename) });
打印結果:
這裏也貼下 glob-stream 的執行流程和源碼註解:
'use strict'; var through2 = require('through2'); var Combine = require('ordered-read-streams'); var unique = require('unique-stream'); var glob = require('glob'); var micromatch = require('micromatch'); var resolveGlob = require('to-absolute-glob'); var globParent = require('glob-parent'); var path = require('path'); var extend = require('extend'); var gs = { // 爲單個 glob 建立流 createStream: function(ourGlob, negatives, opt) { // 使用 path.resolve 將 golb 轉爲絕對路徑(加上 cwd 前綴) ourGlob = resolveGlob(ourGlob, opt); var ourOpt = extend({}, opt); delete ourOpt.root; // 經過 glob pattern 生成一個 Glob 對象(屬於一個事件發射器<EventEmitter>) var globber = new glob.Glob(ourGlob, ourOpt); // 抽取出 glob 的根路徑 var basePath = opt.base || globParent(ourGlob) + path.sep; // Create stream and map events from globber to it var stream = through2.obj(opt, negatives.length ? filterNegatives : undefined); var found = false; //Glob 對象開始註冊事件 globber.on('error', stream.emit.bind(stream, 'error')); globber.once('end', function() { if (opt.allowEmpty !== true && !found && globIsSingular(globber)) { stream.emit('error', new Error('File not found with singular glob: ' + ourGlob)); } stream.end(); }); //註冊匹配到文件時的事件回調 globber.on('match', function(filename) { //標記已匹配到文件(filename 爲文件路徑) found = true; //寫入流(觸發 stream 的 _transform 內置方法) stream.write({ cwd: opt.cwd, base: basePath, path: path.normalize(filename) }); }); return stream; //定義 _transform 方法,過濾掉排除模式所排除的文件 function filterNegatives(filename, enc, cb) { //filename 是匹配到的文件對象 var matcha = isMatch.bind(null, filename); if (negatives.every(matcha)) { cb(null, filename); //把匹配到的文件推送入緩存(供下游消費) } else { cb(); // 忽略 } } }, // 爲多個globs建立流 create: function(globs, opt) { //預設參數處理 if (!opt) { opt = {}; } if (typeof opt.cwd !== 'string') { opt.cwd = process.cwd(); } if (typeof opt.dot !== 'boolean') { opt.dot = false; } if (typeof opt.silent !== 'boolean') { opt.silent = true; } if (typeof opt.nonull !== 'boolean') { opt.nonull = false; } if (typeof opt.cwdbase !== 'boolean') { opt.cwdbase = false; } if (opt.cwdbase) { opt.base = opt.cwd; } //若是 glob(第一個參數)非數組,那麼把它轉爲 [glob],方便後續調用 forEach 方法 if (!Array.isArray(globs)) { globs = [globs]; } var positives = []; var negatives = []; var ourOpt = extend({}, opt); delete ourOpt.root; //遍歷傳入的 glob globs.forEach(function(glob, index) { //驗證 glob 是否有效 if (typeof glob !== 'string' && !(glob instanceof RegExp)) { throw new Error('Invalid glob at index ' + index); } //是否排除模式(如「!b*.js」) var globArray = isNegative(glob) ? negatives : positives; // 排除模式的 glob 初步處理 if (globArray === negatives && typeof glob === 'string') { // 使用 path.resolve 將 golb 轉爲絕對路徑(加上 cwd 前綴) var ourGlob = resolveGlob(glob, opt); //micromatch.matcher(ourGlob, ourOpt) 返回了一個方法,可傳入文件路徑做爲參數,來判斷是否匹配該排除模式的 glob(即返回Boolean) glob = micromatch.matcher(ourGlob, ourOpt); } globArray.push({ index: index, glob: glob }); }); //globs必須最少有一個匹配模式(即非排除模式)的glob,不然報錯 if (positives.length === 0) { throw new Error('Missing positive glob'); } // 只有一條匹配模式,直接生成流並返回 if (positives.length === 1) { return streamFromPositive(positives[0]); } // 建立 positives.length 個獨立的流(數組) var streams = positives.map(streamFromPositive); // 這裏使用了 ordered-read-streams 模塊將一個數組的 Streams 合併爲單個 Stream var aggregate = new Combine(streams); //對合成的 Stream 進行去重處理(以「path」屬性爲指標) var uniqueStream = unique('path'); var returnStream = aggregate.pipe(uniqueStream); aggregate.on('error', function(err) { returnStream.emit('error', err); }); return returnStream; //返回最終匹配完畢(去除了排除模式globs的文件)的文件流 function streamFromPositive(positive) { var negativeGlobs = negatives.filter(indexGreaterThan(positive.index)) //過濾,排除模式的glob必須排在匹配模式的glob後面 .map(toGlob); //返回該匹配模式glob後面的所有排除模式globs(數組形式) return gs.createStream(positive.glob, negativeGlobs, opt); } } }; function isMatch(file, matcher) { //matcher 即單個排除模式的 glob 方法(可傳入文件路徑做爲參數,來判斷是否匹配該排除模式的 glob) //此舉是拿匹配到的文件(file)和排除模式GLOP規則作匹配,若相符(如「a/b.txt」匹配「!a/c.txt」)則爲true if (typeof matcher === 'function') { return matcher(file.path); } if (matcher instanceof RegExp) { return matcher.test(file.path); } } function isNegative(pattern) { if (typeof pattern === 'string') { return pattern[0] === '!'; } if (pattern instanceof RegExp) { return true; } } function indexGreaterThan(index) { return function(obj) { return obj.index > index; }; } function toGlob(obj) { return obj.glob; } function globIsSingular(glob) { var globSet = glob.minimatch.set; if (globSet.length !== 1) { return false; } return globSet[0].every(function isString(value) { return typeof value === 'string'; }); } module.exports = gs;
留意經過 glob-stream 建立的流中,所寫入的數據:
stream.write({
cwd: opt.cwd,
base: basePath,
path: path.normalize(filename)
});
是不像極了 Vinyl 建立文件對象時可傳入的配置。
咱們回過頭來專一 vinyl-fs 的源碼,其入口文件以下:
'use strict'; module.exports = { src: require('./lib/src'), dest: require('./lib/dest'), symlink: require('./lib/symlink') };
下面分別對這三個對外接口(也直接就是 gulp 的對應接口)進行分析。
2.1 gulp.src
該接口文件爲 lib/src/index.js,代碼量很少,但引用的模塊很多。
主要功能是使用 glob-stream 匹配 GLOB 並建立 glob 流,經過 through2 寫入 Object Mode 的 Stream 去,把數據初步加工爲 Vinyl 對象,再按照預設項進行進一步加工處理,最終返回輸出流:
代碼主體部分以下:
function createFile(globFile, enc, cb) { //經過傳入 globFile 來建立一個 vinyl 文件對象 //並賦予 cb 回調(這個回調一看就是 transform stream 的格式,將vinyl 文件對象注入流中) cb(null, new File(globFile)); } function src(glob, opt) { // 配置項初始化 var options = assign({ read: true, buffer: true, sourcemaps: false, passthrough: false, followSymlinks: true }, opt); var inputPass; // 判斷是否有效的 glob pattern if (!isValidGlob(glob)) { throw new Error('Invalid glob argument: ' + glob); } // 經過 glob-stream 建立匹配到的 globStream var globStream = gs.create(glob, options); //加工處理生成輸出流 var outputStream = globStream //globFile.path 爲 symlink的狀況下,轉爲硬連接 .pipe(resolveSymlinks(options)) //建立 vinyl 文件對象供下游處理 .pipe(through.obj(createFile)); // since 可賦與一個 Date 或 number,來要求指定某時間點後修改過的文件 if (options.since != null) { outputStream = outputStream // 經過 through2-filter 檢測 file.stat.mtime 來過濾 .pipe(filterSince(options.since)); } // read 選項默認爲 true,表示容許文件內容可讀(爲 false 時不可讀 且將沒法經過 .dest 方法寫入硬盤) if (options.read !== false) { outputStream = outputStream //獲取文件內容,寫入file.contents 屬性去。 //預設爲 Buffer 時經過 fs.readFile 接口獲取 //不然爲 Stream 類型,經過 fs.createReadStream 接口獲取 .pipe(getContents(options)); } // passthrough 爲 true 時則將 Transform Stream 轉爲 Duplex 類型(默認爲false) if (options.passthrough === true) { inputPass = through.obj(); outputStream = duplexify.obj(inputPass, merge(outputStream, inputPass)); } //是否要開啓 sourcemap(默認爲false),若爲 true 則將流推送給 gulp-sourcemaps 去初始化, //後續在 dest 接口裏再調用 sourcemaps.write(opt.sourcemaps) 將 sourcemap 文件寫入流 if (options.sourcemaps === true) { outputStream = outputStream .pipe(sourcemaps.init({loadMaps: true})); } globStream.on('error', outputStream.emit.bind(outputStream, 'error')); return outputStream; } module.exports = src;
這裏有個 symlink 的概念 —— symlink 即 symbolic link,也稱爲軟鏈(soft link),它使用了其它文件或文件夾的連接來指向一個文件。一個 symlink 能夠連接任何電腦上的任意文件或文件夾。在 Linux/Unix 系統上,symlink 能夠經過 ln 指令來建立;在 windows 系統上能夠經過 mklink 指令來建立。
更多 symlink 的介紹建議參考 wiki —— https://en.wikipedia.org/wiki/Symbolic_link。
另外還有一個很是很是重要的注意事項 —— 在 src 接口開頭的 option 缺省配置時,是默認設置文件要讀取的類型爲 Buffer 的:
// 配置項初始化 var options = assign({ read: true, buffer: true, //默認文件讀取爲buffer類型 sourcemaps: false, passthrough: false, followSymlinks: true }, opt);
雖然 Stream 能有效提高處理性能,但事實上不少 gulp 插件都僅僅支持傳入 Buffer 類型的文件,由於有些操做(例如壓縮混淆腳本),若是沒有先把整個文件內容都讀取出來的話,是容易出問題的。
2.2 gulp.dest
該接口文件爲 lib/dest/index.js,其主要做用天然是根據 src 接口透傳過來的輸出流,生成指定路徑的目標文件/文件夾:
function dest(outFolder, opt) { if (!opt) { opt = {}; } // _transform 接口 function saveFile(file, enc, cb) { // 寫入文件以前的準備處理,主要是 opt 初始化、file對象的 path/base/cwd 等屬性 // 修改成相對 outFolder 的路徑,方便後面 writeContents 生成正確的目的文件 prepareWrite(outFolder, file, opt, function(err, writePath) { if (err) { return cb(err); } //經過 fs.writeFile / fs.createWriteStream 等接口來寫入和建立目標文件/文件夾 writeContents(writePath, file, cb); }); } // 生成 sourcemap 文件(注意這裏的 opt.sourcemaps 如有則應爲指定路徑) var mapStream = sourcemaps.write(opt.sourcemaps); var saveStream = through2.obj(saveFile); // 合併爲單條 duplex stream var outputStream = duplexify.obj(mapStream, saveStream); //生成目標文件/文件夾 mapStream.pipe(saveStream); //依舊返回輸出流(duplex stream) return outputStream; } module.exports = dest;
此處也有一點很值得了解的地方 —— 當輸出文件爲 Buffer 類型時(大部分狀況下),使用的是異步的 fs.writeFile 接口,而在 grunt 中使用的是阻塞的 fs.writeFileSync 接口(參考 grunt/file.js),這是即便 gulp 默認使用 Buffer 傳遞文件內容,但速度相比 grunt 依舊會快不少的重要緣由。
接前文的流程圖:
至此咱們就搞清楚了 gulp 的 src 和 dest 是怎樣運做了。另外 gulp/vinyl-fs 還有一個 symlink 接口,其功能與 gulp.dest 是同樣的,只不過是專門針對 symlink 的方式來處理(使用場景較少),有興趣的同窗能夠自行閱讀其入口文件 lib/symlink/index.js。
本文涉及的全部示例代碼和源碼註釋文件,均存放在個人倉庫(https://github.com/VaJoy/stream/)上,可自行下載調試。共勉~