說起 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,www.90168.org
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 的執行過程從單一進程的形式擴展多進程模式,原本的流程保持不變。整個流程代碼結構上還是比較清晰,在使用過程中,也確實有明顯提升,有興趣的同學可以一起下來交流~