說起 happypack 可能很多同學還比較陌生,其實 happypack 是 webpack 的一個外掛,目的是通過多程式模型,來加速程式碼構建,目前我們的線上伺服器已經上線這個外掛功能,並做了一定適配,效果顯著。這裡有一些大致參考:
這張圖是 happypack 九月逐步全量上線後構建時間的的參考資料,線上構建伺服器 16 核環境。
在上這個外掛的過程中,我們也發現了這個單人維護的社群外掛有一些問題,我們在解決這些問題的同時,也去修改了內部的程式碼,釋出了自己維護的版本 @ali/happypack,那麼內部是怎麼跑起來的,這裡做一個總結記錄。
webpack 載入配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
var HappyPack = require('happypack'); var happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length }); // 省略其餘配置 module: { loaders: [ { test: /\.less$/, loader: ExtractTextPlugin.extract( 'style', path.resolve(__dirname, './node_modules', 'happypack/loader') + '?id=less' ) } ] }, plugins: [ new HappyPack({ id: 'less', loaders: ['css!less'], threadPool: happyThreadPool, cache: true, verbose: true }) ] |
這個示例只單獨抽取了配置 happypack 的部分。可以看到,類似 extract-text-webpack-plugin 外掛,happypack 也是通過 webpack 中 loader 與 plugin 的相互呼叫協作的方式來運作。
loader 配置直接指向 happypack 提供的 loader, 對於檔案實際匹配的處理 loader ,則是通過配置在 plugin 屬性來傳遞說明,這裡 happypack 提供的 loader 與 plugin 的銜接匹配,則是通過 id=less
來完成。
happypack 檔案解析
HappyPlugin.js
對於 webpack 來講,plugin 是貫穿在整個構建流程,同樣對於 happypack 配置的構建流程,首先進入邏輯的是 plugin 的部分,從初始化的部分檢視 happypack 中與 plugin 關聯的檔案。
1. 基礎引數設定
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function HappyPlugin(userConfig) { if (!(this instanceof HappyPlugin)) { return new HappyPlugin(userConfig); } this.id = String(userConfig.id || ++uid); this.name = 'HappyPack'; this.state = { started: false, loaders: [], baseLoaderRequest: '', foregroundWorker: null, }; // 省略 config } |
對於基礎引數的初始化,對應上文提到的配置,可以看到外掛設定了兩個標識
- id: 在配置檔案中設定的與 loader 關聯的 id 首先會設定到例項上,為了後續 loader 與 plugin 能進行一對一匹配
- name: 標識外掛型別為
HappyPack
,方便快速在 loader 中定位對應 plugin,同時也可以避免其他外掛中存在 id 屬性引起錯誤的風險
對於這兩個屬性的應用,可以看到 loader 檔案中有這樣一段程式碼
1 2 3 4 5 6 7 |
function isHappy(id) { return function(plugin) { return plugin.name === 'HappyPack' && plugin.id === id; }; } happyPlugin = this.options.plugins.filter(isHappy(id))[0]; |
其次宣告 state 物件標識外掛的執行狀態之後,開始配置資訊的處理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
function HappyPlugin(userConfig) { // 省略基礎標識設定 this.config = OptionParser(userConfig, { id: { type: 'string' }, tempDir: { type: 'string', default: '.happypack' }, threads: { type: 'number', default: 3 }, threadPool: { type: 'object', default: null }, cache: { type: 'boolean', default: true }, cachePath: { type: 'string' }, cacheContext: { type: 'object', default: {} }, cacheSignatureGenerator: { type: 'function' }, verbose: { type: 'boolean', default: true }, debug: { type: 'boolean', default: process.env.DEBUG === '1' }, enabled: { type: 'boolean', default: true }, loaders: { validate: function(value) { ... }, } }, "HappyPack[" + this.id + "]"); // 省略 threadPool 、HappyFSCache 初始化 } |
呼叫 OptionParser
函式來進行外掛過程中使用到的引數合併,在合併函式的引數物件中,提供了作為資料合併依據的一些屬性,例如合併型別 type
、預設值 default
以及還有設定校驗函式的校驗屬性 validate
完成屬性檢查。
這裡對一些執行過車中的重要屬性進行解釋:
- tmpDir: 存放打包快取檔案的位置
- cache: 是否開啟快取,目前快取如果開啟,(注: 會以數量級的差異來縮短構建時間,很方便日常開發)
- cachePath: 存放快取檔案對映配置的位置
- verbose: 是否輸出過程日誌
- loaders: 因為配置中檔案的處理 loader 都指向了 happypack 提供的 loadr ,這裡配置的對應檔案實際需要執行的 loader
2. 執行緒池初始化
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function HappyPlugin(userConfig) { // 省略基礎引數設定 this.threadPool = this.config.threadPool || HappyThreadPool({ id: this.id, size: this.config.threads, verbose: this.config.verbose, debug: this.config.debug, }); // 省略 HappyFSCache 初始化 } |
這裡的 thread 其實嚴格意義說是 process,應該是程式,猜測只是套用的傳統軟體的一個主程式多個執行緒的模型。這裡不管是在配置中,配置的是 threads
屬性還是 threadPool
屬性,都會生成一個 HappyThreadPool
物件來管理生成的子程式物件。
2.1. HappyThreadPool.js
1 2 3 4 5 6 7 8 9 10 11 |
function HappyThreadPool(config) { var happyRPCHandler = new HappyRPCHandler(); var threads = createThreads(config.size, happyRPCHandler, { id: config.id, verbose: config.verbose, debug: config.debug, }); // 省略返回物件部分 } |
在返回 HappyThreadPool
物件之前,會有兩個操作:
2.1.1. HappyRPCHandler.js
1 2 3 4 |
function HappyRPCHandler() { this.activeLoaders = {}; this.activeCompiler = null; } |
對於 HappyRPCHandler
例項,可以從建構函式看到,會繫結當前執行的 loader 與 compiler ,同時在檔案中,針對 loader 與 compiler 定義呼叫介面:
- 對應 compiler 會繫結查詢解析路徑的 reolve 方法:
12345678COMPILER_RPCs = {resolve: function(compiler, payload, done) {var resolver = compiler.resolvers.normal;var resolve = compiler.resolvers.normal.resolve;// 省略部分判斷resolve.call(resolver, payload.context, payload.context, payload.resource, done);},};
- 對應 loader 其中一些繫結:
1234567891011121314LOADER_RPCS = {emitWarning: function(loader, payload) {loader.emitWarning(payload.message);},emitError: function(loader, payload) {loader.emitError(payload.message);},addDependency: function(loader, payload) {loader.addDependency(payload.file);},addContextDependency: function(loader, payload) {loader.addContextDependency(payload.file);},};
通過定義呼叫 webpack 流程過程中的 loader、compiler 的能力來完成功能,類似傳統服務中的 RPC 過程。
2.1.2. 建立子程式 (HappyThread.js)
傳遞子程式數引數 config.size
以及之前生成的 HappyRPCHandler 物件,呼叫createThreads
方法生成實際的子程式。
1 2 3 4 5 6 7 8 9 10 |
function createThreads(count, happyRPCHandler, config) { var set = [] for (var threadId = 0; threadId < count; ++threadId) { var fullThreadId = config.id ? [ config.id, threadId ].join(':') : threadId; set.push(HappyThread(fullThreadId, happyRPCHandler, config)); } return set; } |
fullThreadId
生成之後,傳入 HappyThread
方法,生成對應的子程式,然後放在 set 集合中返回。呼叫 HappyThread
返回的物件就是 Happypack
的編譯 worker 的上層控制。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
HappyThread: { open: function(onReady) { fd = fork(WORKER_BIN, [id], { execArgv: [] }); // 省略程式訊息繫結處理 }, configure: function(compilerOptions, done) { // 省略具體過程 }, compile: function(params, done) { // 省略具體過程 }, isOpen: function() { return !!fd; }, close: function() { fd.kill('SIGINT'); fd = null; }, |
物件中包含了對應的程式狀態控制 open
、close
,以及通過子程式來實現編譯的流程控制configure
、compile
。
2.1.2.1 子程式執行檔案 HappyWorkerChannel.js
上面還可以看到一個資訊是,fd
子程式的執行檔案路徑變數 WORKER_BIN
,這裡對應的是相同目錄下的 HappyWorkerChannel.js
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
var HappyWorker = require('./HappyWorker'); if (process.argv[1] === __filename) { startAsWorker(); } function startAsWorker() { HappyWorkerChannel(String(process.argv[2]), process); } function HappyWorkerChannel(id, stream) { var worker = new HappyWorker({ compiler: fakeCompiler }); stream.on('message', accept); stream.send({ name: 'READY' }); function accept(message) { // 省略函式內容 } } |
精簡之後的程式碼可以看到 fork
子程式之後,最終執行的是 HappyWorkerChannel
函式,這裡的 stream
引數對應的是子程式的 process
物件,用來與主程式進行通訊。
函式的邏輯是通過 stream.on('messgae')
訂閱訊息,控制層 HappyThread
物件來傳遞訊息進入子程式,通過 accept()
方法來路由訊息進行對應編譯操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
function accept(message) { if (message.name === 'COMPILE') { worker.compile(message.data, function(result) { stream.send({ id: message.id, name: 'COMPILED', sourcePath: result.sourcePath, compiledPath: result.compiledPath, success: result.success }); }); } else if (message.name === 'COMPILER_RESPONSE') { // 省略具體流程 } else if (message.name === 'CONFIGURE') { // 省略具體流程 } else { // 省略具體流程 } } |
對於不同的上層訊息進行不通的子程式處理。
2.1.2.1.1 子程式編譯邏輯檔案 HappyWorker.js
這裡的核心方法 compile
,對應了一層 worker
抽象,包含 Happypack
的實際編譯邏輯,這個物件的建構函式對應 HappyWorker.js
的程式碼。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
HappyWorker.js HappyWorker.prototype.compile = function(params, done) { applyLoaders({ compiler: this._compiler, loaders: params.loaders, loaderContext: params.loaderContext, }, params.loaderContext.sourceCode, params.loaderContext.sourceMap, function(err, source, sourceMap) { // 省略部分判斷 var compiledPath = params.compiledPath; var success = false; // 省略錯誤處理 fs.writeFileSync(compiledPath, source); fs.writeFileSync(compiledPath + '.map', SourceMapSerializer.serialize(sourceMap)); success = true; done({ sourcePath: params.loaderContext.resourcePath, compiledPath: compiledPath, success: success }); }); |
從 applyLoaders
的引數看到,這裡會把 webpack 編輯過程中的 loaders
、loaderContext
通過最上層的 HappyPlugin
進行傳遞,來模擬實現 loader 的編譯操作。
從回撥函式中看到當編譯完成時, fs.writeFileSync(compiledPath, source);
會將編譯結果寫入 compilePath
這個編譯路徑,並通過 done
回撥返回編譯結果給主程式。
3. 編譯快取初始化
happypack
會將每一個檔案的編譯進行快取,這裡通過
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function HappyPlugin(userConfig) { // 省略基礎引數設定 // 省略 threadPool 初始化 this.cache = HappyFSCache({ id: this.id, path: this.config.cachePath ? path.resolve(this.config.cachePath.replace(/\[id\]/g, this.id)) : path.resolve(this.config.tempDir, 'cache--' + this.id + '.json'), verbose: this.config.verbose, generateSignature: this.config.cacheSignatureGenerator }); HappyUtils.mkdirSync(this.config.tempDir); } |
這裡的 cachePath
預設會將 plugin 的 tmpDir
的目錄作為生成快取對映配置檔案的目錄路徑。同時建立好 config.tempDir
目錄。
3.1 happypack 快取控制 HappyFSCache.js
HappyFSCache
函式這裡返回對應的 cache 物件,在編譯的開始和 worker 編譯完成時進行快取載入、設定等操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
// HappyFSCache.js exports.load = function(currentContext) {}; exports.save = function() {}; exports.getCompiledSourceCodePath = function(filePath) { return cache.mtimes[filePath] && cache.mtimes[filePath].compiledPath; }; exports.updateMTimeFor = function(filePath, compiledPath, error) { cache.mtimes[filePath] = { mtime: generateSignature(filePath), compiledPath: compiledPath, error: error }; }; exports.getCompiledSourceMapPath = function(filePath) {}; exports.hasChanged = function(filePath) {}; exports.hasErrored = function(filePath) {}; exports.invalidateEntryFor = function(filePath) {}; exports.dump = function() {}; |
對於編譯過程中的單個檔案,會通過 getCompiledSourceCodePath
函式來獲取對應的快取內容的檔案物理路徑,同時在新檔案編譯完整之後,會通過 updateMTimeFor
來進行快取設定的更新。
HappyLoader.js
在 happypack 流程中,配置的對應 loader 都指向了 happypack/loader.js
,檔案對應匯出的是 HappyLoader.js
匯出的物件 ,對應的 bundle 檔案處理都通過 happypack
提供的 loader 來進行編譯流程。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
function HappyLoader(sourceCode, sourceMap) { var happyPlugin, happyRPCHandler; var callback = this.async(); var id = getId(this.query); happyPlugin = this.options.plugins.filter(isHappy(id))[0]; happyPlugin.compile({ remoteLoaderId: remoteLoaderId, sourceCode: sourceCode, sourceMap: sourceMap, useSourceMap: this._module.useSourceMap, context: this.context, request: happyPlugin.generateRequest(this.resource), resource: this.resource, resourcePath: this.resourcePath, resourceQuery: this.resourceQuery, target: this.target, }, function(err, outSourceCode, outSourceMap) { callback(null, outSourceCode, outSourceMap); }); } |
省略了部分程式碼,HappyLoader
首先拿到配置 id
,然後對所有的 webpack plugin 進行遍歷
1 2 3 4 5 |
function isHappy(id) { return function(plugin) { return plugin.name === 'HappyPack' && plugin.id === id; }; } |
找到 id 匹配的 happypackPlugin
。傳遞原有 webpack
編譯提供的 loaderContext
(loader 處理函式中的 this
物件)中的引數,呼叫 happypackPlugin
的 compile
進行編譯。
上面是 happypack 的主要檔案,作者在專案介紹中也提供了一張圖來進行結構化描述:
實際執行
從前面的檔案解析,已經把 happypack
的工程檔案關聯結構大致說明了一下,這下結合日常在構建工程的一個例子,將整個流程串起來說明。
啟動入口
在 webpack 編譯流程中,在完成了基礎的配置之後,就開始進行編譯流程,這裡 webpack 中的 compiler
物件會去觸發 run
事件,這邊 HappypackPlugin
以這個事件作為流程入口,進行初始化。
1 2 3 4 5 |
HappyPlugin.prototype.apply = function(compiler) { ... compiler.plugin('run', that.start.bind(that)); ... } |
當 run
事件觸發時,開始進行 start
整個流程
1 2 3 4 5 6 7 8 9 10 11 |
HappyPlugin.prototype.start = function(compiler, done) { var that = this; async.series([ function registerCompilerForRPCs(callback) {}, function normalizeLoaders(callback) {}, function resolveLoaders(callback) {}, function loadCache(callback) {}, function launchAndConfigureThreads(callback) {}, function markStarted(callback) {} ], done); }; |
start
函式通過 async.series
將整個過程串聯起來。
1. registerCompilerForRPCs: RPCHandler
繫結 compiler
1 2 3 4 5 |
function registerCompilerForRPCs(callback) { that.threadPool.getRPCHandler().registerActiveCompiler(compiler); callback(); }, |
通過呼叫 plugin 初始化時生成的 handler 上的方法,完成對 compiler
物件的呼叫繫結。
2. normalizeLoaders: loader 解析
1 2 3 4 5 6 7 8 9 |
// webpack.config.js: new HappyPack({ id: 'less', loaders: ['css!less'], threadPool: happyThreadPool, cache: true, verbose: true }) |
對應中的 webpack
中的 happypackPlugin 的 loaders 配置的處理:
1 2 3 4 5 6 7 8 9 10 11 |
function normalizeLoaders(callback) { var loaders = that.config.loaders; // 省略異常處理 that.state.loaders = loaders.reduce(function(list, entry) { return list.concat(WebpackUtils.normalizeLoader(entry)); }, []); callback(null); } |
對應配置的 loaders ,經過 normalizeLoader
的處理後,例如 [css!less]
會返回成一個loader
陣列 [{path: 'css'},{path: 'less'}]
,複製到 plugin 的 this.state
屬性上。
3.resolveLoaders: loader 對應檔案路徑查詢
1 2 3 4 5 6 7 8 9 10 11 |
function resolveLoaders(callback) { var loaderPaths = that.state.loaders.map(function(loader) { return loader.path; }); WebpackUtils.resolveLoaders(compiler, loaderPaths, function(err, loaders) { that.state.loaders = loaders; that.state.baseLoaderRequest = loaders.map(function(loader) { return loader.path + (loader.query || ''); }).join('!'); callback(); }); } |
為了實際執行 loader 過程,這裡將上一步 loader 解析 處理過後的 loaders
陣列傳遞到resolveLoaders
方法中,進行解析
1 2 3 4 5 6 7 8 9 10 11 12 13 |
exports.resolveLoaders = function(compiler, loaders, done) { var resolve = compiler.resolvers.loader.resolve; var resolveContext = compiler.resolvers.loader; async.parallel(loaders.map(function(loader) { return function(callback) { var callArgs = [ compiler.context, loader, function(err, result) { callback(null, extractPathAndQueryFromString(result)); }]; resolve.apply(resolveContext, callArgs); }; }), done); }; |
而 resolveLoaders
方法採用的是借用原有 webpack
的 compiler 物件上的對應resolvers.loader
這個 Resolver
例項的 resolve
方法進行解析,構造好解析引數後,通過async.parallel
並行解析 loader 的路徑
4.loadCache: cache 載入
1 2 3 4 5 6 7 8 9 10 |
function loadCache(callback) { if (that.config.cache) { that.cache.load({ loaders: that.state.loaders, external: that.config.cacheContext }); } callback(); } |
cache 載入通過呼叫 cache.load
方法來載入上一次構建的快取,快速提高構建速度。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
exports.load = function(currentContext) { var oldCache, staleEntryCount; cache.context = currentContext; try { oldCache = JSON.parse(fs.readFileSync(cachePath, 'utf-8')); } catch(e) { oldCache = null; } cache.mtimes = oldCache.mtimes; cache.context = currentContext; staleEntryCount = removeStaleEntries(cache.mtimes, generateSignature); return true; }; |
load
方法會去讀取 cachePath
這個路徑的快取配置檔案,然後將內容設定到當前 cache
物件上的 mtimes
上。
在 happypack 設計的構建快取中,存在一個上述的一個快取對映檔案,裡面的配置會對映到一份編譯生成的快取檔案。
5.launchAndConfigureThreads: 執行緒池啟動
1 2 3 4 5 |
function launchAndConfigureThreads(callback) { that.threadPool.start(function() { // 省略 thread congigure 過程 }); }, |
上面有提到,在載入完 HappyPlugin
時,會建立對應的 HappyThreadPool
物件以及設定數量的 HappyThread
。但實際上一直沒有建立真正的子程式例項,這裡通過呼叫threadPool.start
來進行子程式建立。
1 2 3 4 5 |
HappyThreadPool.js: start: function(done) { async.parallel(threads.filter(not(send('isOpen'))).map(get('open')), done); } |
start
方法通過 send
、not
、get
這三個方法來進行過濾、啟動的串聯。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
HappyThreadPool.js: function send(method) { return function(receiver) { return receiver[method].call(receiver); }; } function not(f) { return function(x) { return !f(x); }; } function get(attr) { return function(object) { return object[attr]; }; } |
傳遞 'isOpen'
到 send
返回函式中,receiver
物件繫結呼叫 isOpen
方法;再傳遞給 not
返回函式中,返回前面函式結構取反。傳遞給 threads
的 filter
方法進行篩選;最後通過 get
傳遞返回的 open
屬性。
1 2 3 4 5 |
HappyThread.js isOpen: function() { return !!fd; } |
在 HappyThread
物件中 isOpen
通過判斷 fd
變數來判斷是否建立子程式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
open: function(onReady) { var emitReady = Once(onReady); fd = fork(WORKER_BIN, [id], { execArgv: [] }); fd.on('error', throwError); fd.on('exit', function(exitCode) { if (exitCode !== 0) { emitReady('HappyPack: worker exited abnormally with code ' + exitCode); } }); fd.on('message', function acceptMessageFromWorker(message) { if (message.name === 'READY') { emitReady(); } else if (message.name === 'COMPILED') { var filePath = message.sourcePath; callbacks[message.id](message); delete callbacks[message.id]; } }); } |
HappyThread
物件的 open
方法首先將 async.parallel
傳遞過來的 callback
鉤子通過Once
方法封裝,避免多次觸發,返回成 emitReady
函式。
然後呼叫 childProcess.fork
傳遞 HappyWorkerChannel.js
作為子程式執行檔案來建立一個子程式,繫結對應的 error
、exit
異常情況的處理,同時繫結最為重要的 message
事件,來接受子程式發來的處理訊息。而這裡 COMPILED
訊息就是對應的子程式完成編譯之後會發出的訊息。
1 2 3 4 5 6 7 8 9 10 11 |
// HappyWorkerChannel.js function HappyWorkerChannel(id, stream) { var fakeCompiler = new HappyFakeCompiler(id, stream.send.bind(stream)); var worker = new HappyWorker({ compiler: fakeCompiler }); stream.on('message', accept); stream.send({ name: 'READY' }); // 省略訊息處理 } |
在子程式完成建立之後,會向主程式傳送一個 READY
訊息,表明已經完成建立,在主程式接受到 READY
訊息後,會呼叫前面封裝的 emitReady
,來反饋給 async.parallel
表示完成open
流程。
6.markStarted: 標記啟動
1 2 3 4 |
function markStarted(callback) { that.state.started = true; callback(); } |
最後一步,在完成之前的步驟後,修改狀態屬性 started
為 true
,完成整個外掛的啟動過程。
編譯執行
1. loader 傳遞
在 webpack 流程中,在原始碼檔案完成內容讀取之後,開始進入到 loader 的編譯執行階段,這時 HappyLoader
作為編譯邏輯入口,開始進行編譯流程。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function HappyLoader(sourceCode, sourceMap) { // 省略 Plugin 查詢 happyPlugin.compile({ remoteLoaderId: remoteLoaderId, sourceCode: sourceCode, sourceMap: sourceMap, useSourceMap: this._module.useSourceMap, context: this.context, request: happyPlugin.generateRequest(this.resource), resource: this.resource, resourcePath: this.resourcePath, resourceQuery: this.resourceQuery, target: this.target, }, function(err, outSourceCode, outSourceMap) { callback(null, outSourceCode, outSourceMap); }); } |
loader
中將 webpack 原本的 loaderContext(this指向)
物件的一些引數例如this.resource
、this.resourcePath
等透傳到 HappyPlugin.compile
方法進行編譯。
2. plugin 編譯邏輯執行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
HappyPlugin.js: HappyPlugin.prototype.compile = function(loaderContext, done) { // 省略 foregroundWorker 情況 return this.compileInBackground(loaderContext, done); }; HappyPlugin.prototype.compileInBackground = function(loaderContext, done) { var cache = this.cache; var filePath = loaderContext.resourcePath; if (!cache.hasChanged(filePath) && !cache.hasErrored(filePath)) { var cached = this.readFromCache(filePath); return done(null, cached.sourceCode, cached.sourceMap); } this._performCompilationRequest(this.threadPool.get(), loaderContext, done); }; |
HappyPlugin
中的 compile
方法對應 build 過程,通過呼叫 compileInBackground
方法來完成呼叫。
2.1 構建快取判斷
在 compileInBackground
中,首先會代用 cache 的 hasChanged
和 hasErrored
方法來判斷是否可以從快取中讀取構建檔案。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// HappyFSCache.js exports.hasChanged = function(filePath) { var nowMTime = generateSignature(filePath); var lastMTime = getSignatureAtCompilationTime(filePath); return nowMTime !== lastMTime; }; exports.hasErrored = function(filePath) { return cache.mtimes[filePath] && cache.mtimes[filePath].error; }; function getSignatureAtCompilationTime(filePath) { if (cache.mtimes[filePath]) { return cache.mtimes[filePath].mtime; } } |
hasError
判斷的是更新快取的時候的 error
屬性是否存在。
hasChanged
中會去比較 nowMTime
與 lastMTime
兩個是否相等。實際上這裡 nowMTime
通過呼叫 generateSignature
(預設是 getMTime
函式) 返回的是檔案目前的最後修改時間,lastMTime
返回的是編譯完成時的修改時間。
1 2 3 |
function getMTime(filePath) { return fs.statSync(filePath).mtime.getTime(); } |
如果 nowMTime
、lastMTime
兩個的最後修改時間相同且不存在錯誤,那麼說明構建可以利用快取
2.1.1 快取生效
如果快取判斷生效,那麼開始呼叫 readFromCache
方法,從快取中讀取構建對應檔案內容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// HappyPlugin.js: HappyPlugin.prototype.readFromCache = function(filePath) { var cached = {}; var sourceCodeFilePath = this.cache.getCompiledSourceCodePath(filePath); var sourceMapFilePath = this.cache.getCompiledSourceMapPath(filePath); cached.sourceCode = fs.readFileSync(sourceCodeFilePath, 'utf-8'); if (HappyUtils.isReadable(sourceMapFilePath)) { cached.sourceMap = SourceMapSerializer.deserialize( fs.readFileSync(sourceMapFilePath, 'utf-8') ); } return cached; }; |
函式的意圖是通過 cache
物件的 getCompiledSourceCodePath
、getCompiledSourceMapPath
方法獲取快取的編譯檔案及 sourcemap 檔案的儲存路徑,然後讀取出來,完成從快取中獲取構建內容。
1 2 3 4 5 6 7 8 9 |
// HappyFSCache.js exports.getCompiledSourceCodePath = function(filePath) { return cache.mtimes[filePath] && cache.mtimes[filePath].compiledPath; }; exports.getCompiledSourceMapPath = function(filePath) { return cache.mtimes[filePath] && cache.mtimes[filePath].compiledPath + '.map'; }; |
獲取的路徑是通過在完成編譯時呼叫的 updateMTimeFor
進行儲存的物件中的 compiledPath
編譯路徑屬性。
2.1.2 快取失效
在快取判斷失效的情況下,進入 _performCompilationRequest
,進行下一步 happypack
編譯流程。
1 2 3 4 5 |
HappyPlugin.prototype.compileInBackground = function(loaderContext, done) { this._performCompilationRequest(this.threadPool.get(), loaderContext, done); } |
在呼叫 _performCompilationRequest
前, 還有一步是從 ThreadPool
獲取對應的子程式封裝物件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// HappyThreadPool.js get: RoundRobinThreadPool(threads), function RoundRobinThreadPool(threads) { var lastThreadId = 0; return function getThread() { var threadId = lastThreadId; lastThreadId++; if (lastThreadId >= threads.length) { lastThreadId = 0; } return threads[threadId]; } } |
這裡按照遞增返回的 round-robin,這種在伺服器程式控制中經常使用的簡潔演算法返回子程式封裝物件。
3. 編譯開始
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
HappyPlugin.prototype._performCompilationRequest = function(worker, loaderContext, done) { var cache = this.cache; var filePath = loaderContext.resourcePath; cache.invalidateEntryFor(filePath); worker.compile({ loaders: this.state.loaders, compiledPath: path.resolve(this.config.tempDir, HappyUtils.generateCompiledPath(filePath)), loaderContext: loaderContext, }, function(result) { var contents = fs.readFileSync(result.compiledPath, 'utf-8') var compiledMap; if (!result.success) { cache.updateMTimeFor(filePath, null, contents); done(contents); } else { cache.updateMTimeFor(filePath, result.compiledPath); compiledMap = SourceMapSerializer.deserialize( fs.readFileSync(cache.getCompiledSourceMapPath(filePath), 'utf-8') ); done(null, contents, compiledMap); } }); }; |
首先對編譯的檔案,呼叫 cache.invalidateEntryFor
設定該檔案路徑的構建快取失效。然後呼叫子程式封裝物件的 compile 方法,觸發子程式進行編譯。
同時會生成銜接主程式、子程式、快取的 compiledPath
,當子程式完成編譯後,會將編譯後的程式碼寫入 compiledPath
,之後傳送完成編譯的訊息回主程式,主程式也是通過compiledPath
獲取構建後的程式碼,同時傳遞 compiledPath
以及對應的編譯前檔案路徑filePath
,更新快取設定。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// HappyThread.js compile: function(params, done) { var messageId = generateMessageId(); callbacks[messageId] = done; fd.send({ id: messageId, name: 'COMPILE', data: params, }); } |
這裡的 messageId 是個從 0 開始的遞增數字,完成回撥方法的儲存註冊,方便完成編譯之後找到回撥方法傳遞資訊回主程式。同時在 thread
這一層,也是將引數透傳給子程式執行編譯。
子程式接到訊息後,呼叫 worker.compile
方法 ,同時進一步傳遞構建引數。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// HappyWorker.js HappyWorker.prototype.compile = function(params, done) { applyLoaders({ compiler: this._compiler, loaders: params.loaders, loaderContext: params.loaderContext, }, params.loaderContext.sourceCode, params.loaderContext.sourceMap, function(err, source, sourceMap) { var compiledPath = params.compiledPath; var success = false; if (err) { console.error(err); fs.writeFileSync(compiledPath, serializeError(err), 'utf-8'); } else { fs.writeFileSync(compiledPath, source); fs.writeFileSync(compiledPath + '.map', SourceMapSerializer.serialize(sourceMap)); success = true; } done({ sourcePath: params.loaderContext.resourcePath, compiledPath: compiledPath, success: success }); }); }; |
在 HappyWorker.js 中的 compile
方法中,呼叫 applyLoaders
進行 loader 方法執行。applyLoaders
是 happypack
中對 webpack
中 loader 執行過程進行模擬,對應 NormalModuleMixin.js 中的 doBuild
方法。完成對檔案的字串處理編譯。
根據 err
判斷是否成功。如果判斷成功,則將對應檔案的編譯後內容寫入之前傳遞進來的compiledPath
,反之,則會把錯誤內容寫入。
在子程式完成編譯流程後,會呼叫傳遞進來的回撥方法,在回撥方法中將編譯資訊返回到主程式,主程式根據 compiledPath
來獲取子程式的編譯內容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
// HappyPlugin.js HappyPlugin.prototype._performCompilationRequest = function(worker, loaderContext, done) { var contents = fs.readFileSync(result.compiledPath, 'utf-8') var compiledMap; if (!result.success) { cache.updateMTimeFor(filePath, null, contents); done(contents); } else { cache.updateMTimeFor(filePath, result.compiledPath); compiledMap = SourceMapSerializer.deserialize( fs.readFileSync(cache.getCompiledSourceMapPath(filePath), 'utf-8') ); done(null, contents, compiledMap); } } |
獲取子程式的編譯內容 contents
後,根據 result.success
屬性來判斷是否編譯成功,如果失敗的話,會將 contents
作為錯誤傳遞進去。
在完成呼叫 updateMTimeFor
快取更新後,最後將內容返回到 HappyLoader.js 中的回撥中,返回到 webpack 的原本流程。
4. 編譯結束
當 webpack 整體編譯流程結束後, happypack
開始進行一些善後工作
1 2 3 4 5 6 7 8 9 10 11 |
// HappyPlugin.js compiler.plugin('done', that.stop.bind(that)); HappyPlugin.prototype.stop = function() { if (this.config.cache) { this.cache.save(); } this.threadPool.stop(); }; |
4.1. 儲存快取配置
首先呼叫 cache.save()
儲存下這個快取的對映設定。
1 2 3 4 5 |
// HappyFSCache.js exports.save = function() { fs.writeFileSync(cachePath, JSON.stringify(cache)); }; |
cache 物件的處理是會將這個檔案直接寫入 cachePath
,這樣就能供下一次 cache.load
方法裝載配置,利用快取。
4.2. 終止子程式
其次呼叫 threadPool.stop
來終止掉程式
1 2 3 4 5 |
// HappyThreadPool.js stop: function() { threads.filter(send('isOpen')).map(send('close')); } |
類似前面提到的 start
方法,這裡是篩選出來正在執行的 HappyThread
物件,呼叫 close
方法。
1 2 3 4 5 6 |
// HappyThread.js close: function() { fd.kill('SIGINT'); fd = null; }, |
在 HappyThread
中,則是呼叫 kill
方法,完成子程式的釋放。
彙總
happypack 的處理思路是將原有的 webpack 對 loader 的執行過程從單一程式的形式擴充套件多程式模式,原本的流程保持不變。整個流程程式碼結構上還是比較清晰,在使用過程中,也確實有明顯提升,有興趣的同學可以一起下來交流~