您的位置:首页 > Web前端 > Webpack

webpack-dev-server原理分析与HMR实现

2017-03-02 17:31 1396 查看
建议在github阅读,我会保证内容及时更新,并欢迎star,issue。如果你想深入了解webpack-dev-server的内部原理,你也可以查看我写的这个打包工具,通过它可以完成三种打包方式,其中devServer模式就是通过webpack-dev-server来完成的,并且支持HMR(webpack-dev-server虽然说可以支持无刷新更新数据,但是在大多数情况下都是刷新页面的,而该打包工具已经无需刷新而完成数据更新了)。对于webpack的HMR不了解的可以查看这里。其中也牵涉到webpack-dev-middleware中间件。希望对您有用

webpack-dev-server在我们的entry中添加的hot模块内容

看看下面的方法你就知道了,在hot模式下,我们的entry最后都会被添加两个文件:

module.exports = function addDevServerEntrypoints(webpackOptions, devServerOptions) {
if(devServerOptions.inline !== false) {
//表示是inline模式而不是iframe模式
const domain = createDomain(devServerOptions);
const devClient = [`${require.resolve("../../client/")}?${domain}`];
//客户端内容
if(devServerOptions.hotOnly)
devClient.push("webpack/hot/only-dev-server");
else if(devServerOptions.hot)
devClient.push("webpack/hot/dev-server");
//配置了不同的webpack而文件到客户端文件中
[].concat(webpackOptions).forEach(function(wpOpt) {
if(typeof wpOpt.entry === "object" && !Array.isArray(wpOpt.entry)) {
/*
entry:{
index:'./index.js',    => entry:[]
index1:'./index1.js'
}
*/
Object.keys(wpOpt.entry).forEach(function(key) {
wpOpt.entry[key] = devClient.concat(wpOpt.entry[key]);
});
//添加我们自己的入口文件
} else if(typeof wpOpt.entry === "function") {
wpOpt.entry = wpOpt.entry(devClient);
//如果entry是一个函数那么我们把devClient数组传入
} else {
wpOpt.entry = devClient.concat(wpOpt.entry);
//数组直接传入
}
});
}
};


(1)首先看看”webpack/hot/only-dev-server”的文件内容:

if(module.hot) {
var lastHash;
//___webpack_hash__
//Access to the hash of the compilation.
//Only available with the HotModuleReplacementPlugin or the ExtendedAPIPlugin
var upToDate = function upToDate() {
return lastHash.indexOf(__webpack_hash__) >= 0;
//如果两个hash相同那么表示没有更新
};
//检查更新
var check = function check() {
//Check all currently loaded modules for updates and apply updates if found.
module.hot.check().then(function(updatedModules) {
//没有更新的模块直接返回
if(!updatedModules) {
console.warn("[HMR] Cannot find update. Need to do a full reload!");
console.warn("[HMR] (Probably because of restarting the webpack-dev-server)");
return;
}
//apply方法:If status() != "ready" it throws an error.
//开始更新
return module.hot.apply({
ignoreUnaccepted: true,
ignoreDeclined: true,
ignoreErrored: true,
onUnaccepted: function(data) {
console.warn("Ignored an update to unaccepted module " + data.chain.join(" -> "));
},
onDeclined: function(data) {
console.warn("Ignored an update to declined module " + data.chain.join(" -> "));
},
onErrored: function(data) {
console.warn("Ignored an error while updating module " + data.moduleId + " (" + data.type + ")");
}
//renewedModules表示哪些模块已经更新了
}).then(function(renewedModules) {
if(!upToDate()) {
check();
}
//更新的模块updatedModules,renewedModules表示哪些模块已经更新了
require("./log-apply-result")(updatedModules, renewedModules);
if(upToDate()) {
console.log("[HMR] App is up to date.");
}
});
}).catch(function(err) {
var status = module.hot.status();
if(["abort", "fail"].indexOf(status) >= 0) {
console.warn("[HMR] Cannot check for update. Need to do a full reload!");
console.warn("[HMR] " + err.stack || err.message);
} else {
console.warn("[HMR] Update check failed: " + err.stack || err.message);
}
});
};
var hotEmitter = require("./emitter");
//emitter模块内容,也就是导出一个events实例
/*
var EventEmitter = require("events");
module.exports = new EventEmitter();
*/
hotEmitter.on("webpackHotUpdate", function(currentHash) {
lastHash = currentHash;
//表示本次更新后得到的hash值
if(!upToDate()) {
//有更新
var status = module.hot.status();
if(status === "idle") {
console.log("[HMR] Checking for updates on the server...");
check();
} else if(["abort", "fail"].indexOf(status) >= 0) {
console.warn("[HMR] Cannot apply update as a previous update " + status + "ed. Need to do a full reload!");
}
}
});
console.log("[HMR] Waiting for update signal from WDS...");
} else {
throw new Error("[HMR] Hot Module Replacement is disabled.");
}


./log-apply-result模块内容如下:

module.exports = function(updatedModules, renewedModules) {
//renewedModules表示哪些模块被更新了,剩余的模块表示,哪些模块由于 ignoreDeclined,ignoreUnaccepted配置没有更新
var unacceptedModules = updatedModules.filter(function(moduleId) {
return renewedModules && renewedModules.indexOf(moduleId) < 0;
});
//哪些模块无法HMR,打印log
if(unacceptedModules.length > 0) {
console.warn("[HMR] The following modules couldn't be hot updated: (They would need a full reload!)");
unacceptedModules.forEach(function(moduleId) {
console.warn("[HMR]  - " + moduleId);
});
}
//没有模块更新,表示模块是最新的
if(!renewedModules || renewedModules.length === 0) {
console.log("[HMR] Nothing hot updated.");
} else {
console.log("[HMR] Updated modules:");
//更新的模块
renewedModules.forEach(function(moduleId) {
console.log("[HMR]  - " + moduleId);
});
//每一个moduleId都是数字那么建议使用NamedModulesPlugin
var numberIds = renewedModules.every(function(moduleId) {
return typeof moduleId === "number";
});
if(numberIds)
console.log("[HMR] Consider using the NamedModulesPlugin for module names.");
}
};


所以”webpack/hot/only-dev-server”的文件内容就是检查哪些模块更新了(通过webpackHotUpdate事件完成),其中哪些模块更新成功,而哪些模块由于某种原因没有更新成功。其中没有更新的原因可能是如下的:

ignoreUnaccepted
ignoreDecline
ignoreErrored


至于模块什么时候接受到需要更新是和webpack的打包过程有关的,这里也给出触发更新的时机:

ok: function() {
sendMsg("Ok");
if(useWarningOverlay || useErrorOverlay) overlay.clear();
if(initial) return initial = false;
reloadApp();
},
warnings: function(warnings) {
log("info", "[WDS] Warnings while compiling.");
var strippedWarnings = warnings.map(function(warning) {
return stripAnsi(warning);
});
sendMsg("Warnings", strippedWarnings);
for(var i = 0; i < strippedWarnings.length; i++)
console.warn(strippedWarnings[i]);
if(useWarningOverlay) overlay.showMessage(warnings);

if(initial) return initial = false;
reloadApp();
},
function reloadApp() {
//如果开启了HMR模式
if(hot) {
log("info", "[WDS] App hot update...");
var hotEmitter = require("webpack/hot/emitter");
hotEmitter.emit("webpackHotUpdate", currentHash);
//重新启动webpack/hot/emitter,同时设置当前hash
if(typeof self !== "undefined" && self.window) {
// broadcast update to window
self.postMessage("webpackHotUpdate" + currentHash, "*");
}
} else {
//如果不是Hotupdate那么我们直接reload我们的window就可以了
log("info", "[WDS] App updated. Reloading...");
self.location.reload();
}
}


也就是说当客户端接受到服务器端发送的ok和warning信息的时候,同时支持HMR的情况下就会要求检查更新,同时发送过来的还有服务器端本次编译的hash值。我们继续深入一步,看看服务器什么时候发送’ok’和’warning’消息:

Server.prototype._sendStats = function(sockets, stats, force) {
if(!force &&
stats &&
(!stats.errors || stats.errors.length === 0) &&
stats.assets &&
stats.assets.every(function(asset) {
return !asset.emitted;
//每一个asset都是没有emitted属性,表示没有发生变化。如果发生变化那么这个assets肯定有emitted属性
})
)
return this.sockWrite(sockets, "still-ok");
this.sockWrite(sockets, "hash", stats.hash);
//设置hash
if(stats.errors.length > 0)
this.sockWrite(sockets, "errors", stats.errors);
else if(stats.warnings.length > 0)
this.sockWrite(sockets, "warnings", stats.warnings);
else
this.sockWrite(sockets, "ok");
}


也就是说更新是通过上面这个方法完成的,我们看看上面这个方法什么时候调用就可以了:

compiler.plugin("done", function(stats) {
this._sendStats(this.sockets, stats.toJson(clientStats));
this._stats = stats;
}.bind(this));


是不是豁然开朗了,也就是每次compiler的’done’钩子函数被调用的时候就会要求客户端去检查模块更新,进而完成HMR基本功能!

(2)再来看看webpack/hot/dev-server

if(module.hot) {
var lastHash;
//__webpack_hash__是每次编译的hash值是全局的
//Only available with the HotModuleReplacementPlugin or the ExtendedAPIPlugin
var upToDate = function upToDate() {
return lastHash.indexOf(__webpack_hash__) >= 0;
};
var check = function check() {
// check([autoApply], callback: (err: Error, outdatedModules: Module[]) => void
// If autoApply is truthy the callback will be called with all modules that were disposed. apply() is automatically called with autoApply as options parameter.(传入哪些代码已经被更新的模块)
//If autoApply is not set the callback will be called with all modules that will be disposed on apply(). (不是true那么传入的是哪些需要被apply处理的模块)
module.hot.check(true).then(function(updatedModules) {
//检查所有要更新的模块,如果没有模块要更新那么回调函数就是null
if(!updatedModules) {
console.warn("[HMR] Cannot find update. Need to do a full reload!");
console.warn("[HMR] (Probably because of restarting the webpack-dev-server)");
window.location.reload();
return;
}
//如果还有更新
if(!upToDate()) {
check();
}
require("./log-apply-result")(updatedModules, updatedModules);
//已经被更新的模块都是updatedModules
if(upToDate()) {
console.log("[HMR] App is up to date.");
}

}).catch(function(err) {
var status = module.hot.status();
//如果报错直接全局reload
if(["abort", "fail"].indexOf(status) >= 0) {
console.warn("[HMR] Cannot apply update. Need to do a full reload!");
console.warn("[HMR] " + err.stack || err.message);
window.location.reload();
} else {
console.warn("[HMR] Update failed: " + err.stack || err.message);
}
});
};
var hotEmitter = require("./emitter");
//获取MyEmitter对象
hotEmitter.on("webpackHotUpdate", function(currentHash) {
lastHash = currentHash;
if(!upToDate() && module.hot.status() === "idle") {
//调用module.hot.status方法获取状态
console.log("[HMR] Checking for updates on the server...");
check();
}
});
console.log("[HMR] Waiting for update signal from WDS...");
} else {
throw new Error("[HMR] Hot Module Replacement is disabled.");
}


也就是说webpack/hot/dev-server相较于前面在入口文件中添加的”webpack/hot/only-dev-server”来说,区别在于后者传入的是哪些已经被更新的模块,也就是已经被自己模块本身dispose处理了。如下:

if (module.hot) {
module.hot.accept();
// dispose handler
module.hot.dispose(() => {
window.clearInterval(intervalId);
});
}


(3)如果你注意到上面其实我们还添加了一个client/index.js,这个客户端代码只是添加了我们的客户端的socket.js代码,这时候我们客户端就可以获取到服务器端发送到的socket命令

var onSocketMsg = {
//设置hot为true
hot: function() {
hot = true;
log("info", "[WDS] Hot Module Replacement enabled.");
},
//打印invalid
invalid: function() {
log("info", "[WDS] App updated. Recompiling...");
sendMsg("Invalid");
},
//设置hash
hash: function(hash) {
currentHash = hash;
},
//继续可用
"still-ok": function() {
log("info", "[WDS] Nothing changed.")
if(useWarningOverlay || useErrorOverlay) overlay.clear();
sendMsg("StillOk");
},
//设置log级别
"log-level": function(level) {
logLevel = level;
},
/*
Shows a full-screen overlay in the browser when there are compiler errors or warnings.
Disabled by default. If you want to show only compiler errors:
overlay: true
If you want to show warnings as well as errors:
overlay: {
warnings: true,
errors: true
}
*/
"overlay": function(overlay) {
if(typeof document !== "undefined") {
if(typeof(overlay) === "boolean") {
useWarningOverlay = overlay;
useErrorOverlay = overlay;
} else if(overlay) {
useWarningOverlay = overlay.warnings;
useErrorOverlay = overlay.errors;
}
}
},
//ok
ok: function() {
sendMsg("Ok");
if(useWarningOverlay || useErrorOverlay) overlay.clear();
if(initial) return initial = false;
reloadApp();
},
//客户端检测到服务器端有更新,通过chokidar检测到文件的变化
"content-changed": function() {
log("info", "[WDS] Content base changed. Reloading...")
self.location.reload();
},
warnings: function(warnings) {
log("info", "[WDS] Warnings while compiling.");
var strippedWarnings = warnings.map(function(warning) {
return stripAnsi(warning);
});
sendMsg("Warnings", strippedWarnings);
for(var i = 0; i < strippedWarnings.length; i++)
console.warn(strippedWarnings[i]);
if(useWarningOverlay) overlay.showMessage(warnings);

if(initial) return initial = false;
reloadApp();
},
errors: function(errors) {
log("info", "[WDS] Errors while compiling. Reload prevented.");
var strippedErrors = errors.map(function(error) {
return stripAnsi(error);
});
sendMsg("Errors", strippedErrors);
for(var i = 0; i < strippedErrors.length; i++)
console.error(strippedErrors[i]);
if(useErrorOverlay) overlay.showMessage(errors);
},
//发送消息close
close: function() {
log("error", "[WDS] Disconnected!");
sendMsg("Close");
}
};
socket(socketUrl, onSocketMsg);


module.hot等相关方法什么时候被调用

其实通过上面的分析,我们肯定有一点疑问就是,我们的这些module.hot等方法是在什么时候调用的,其实看看HotModuleReplacementPlugin就明白了,下面贴出一部分代码:

parser.plugin("call module.hot.accept", function(expr) {
if(!this.state.compilation.hotUpdateChunkTemplate) return false;
if(expr.arguments.length >= 1) {
var arg = this.evaluateExpression(expr.arguments[0]);
var params = [],
requests = [];
if(arg.isString()) {
params = [arg];
} else if(arg.isArray()) {
params = arg.items.filter(function(param) {
return param.isString();
});
}
if(params.length > 0) {
params.forEach(function(param, idx) {
var request = param.string;
var dep = new ModuleHotAcceptDependency(request, param.range);
dep.optional = true;
dep.loc = Object.create(expr.loc);
dep.loc.index = idx;
this.state.module.addDependency(dep);
requests.push(request);
}.bind(this));
if(expr.arguments.length > 1)
this.applyPluginsBailResult("hot accept callback", expr.arguments[1], requests);
else
this.applyPluginsBailResult("hot accept without callback", expr, requests);
}
}
});
parser.plugin("call module.hot.decline", function(expr) {
if(!this.state.compilation.hotUpdateChunkTemplate) return false;
if(expr.arguments.length === 1) {
var arg = this.evaluateExpression(expr.arguments[0]);
var params = [];
if(arg.isString()) {
params = [arg];
} else if(arg.isArray()) {
params = arg.items.filter(function(param) {
return param.isString();
});
}
params.forEach(function(param, idx) {
var dep = new ModuleHotDeclineDependency(param.string, param.range);
dep.optional = true;
dep.loc = Object.create(expr.loc);
dep.loc.index = idx;
this.state.module.addDependency(dep);
}.bind(this));
}
});
parser.plugin("expression module.hot", function() {
return true;
});
});
});


也就是我们关注的这些module.hot.decline方法都是在Parser上封装的!

如何写出支持HMR的代码

这里就是一个例子,你也可以查看这个仓库,然后克隆下来,执行”node ./bin/wcf –dev”命令,你就会发现访问localhost:8080的时候代码是可以支持HMR(你可以修改test目录下的所有的文件),而不会出现页面刷新的情况。

import * as dom from './dom';
import * as time from './time';
import pulse from './pulse';
require('./styles.scss');
const UPDATE_INTERVAL = 1000; // milliseconds
const intervalId = window.setInterval(() => {
dom.writeTextToElement('upTime', time.getElapsedSeconds() + ' seconds');
dom.writeTextToElement('lastPulse', pulse());
}, UPDATE_INTERVAL);
// Activate Webpack HMR
if (module.hot) { module.hot.accept(); // dispose handler module.hot.dispose(() => { window.clearInterval(intervalId); }); }


其中accept函数签名如下:

accept(dependencies: string[], callback: (updatedDependencies) => void) => void
accept(dependency: string, callback: () => void) => void
//直接接受当前模块某一个依赖模块的HMR


此时表示,我们这个模块支持HMR,任何其依赖的模块变化都会被捕捉到。当依赖的模块更新后回调函数被调用。当然,如果是下面这种方式:

accept([errHandler]) => void


那么表示我们接受当前模块所有依赖的模块的代码更新,而且这种更新不会冒泡到父级中去。这当我们模块没有导出任何东西的情况下有用(比如entry)。

其中decline函数

上面的例子中我们的dom.js是如下方式写的:

import $ from 'jquery';
export function writeTextToElement(id, text) {
$('#' + id).text(text);
}
if (module.hot) {
module.hot.decline('jquery');//不接受jquery更新
}


其中decline方法签名如下:

decline(dependencies: string[]) => void
decline(dependency: string) => void


这表明我们不会接受特定模块的更新,如果该模块更新了,那么更新失败同时失败代码为”decline”。而上面的代码表明我们不会接受jquery模块的更新。当前也可以是如下模式:

decline() => void


这表明我们当前的模块是不会更新的,也就是不会HMR。如果更新了那么错误代码为”decline”;

其中dispose函数

函数签名如下:

dispose(callback: (data: object) => void) => void
addDisposeHandler(callback: (data: object) => void) => void


这表示我们会添加一个一次性的处理函数,这个函数在当前模块更新后会被调用。此时,你需要移除或者销毁一些持久的资源,如果你想将当前的状态信息转移到更新后的模块中,此时可以添加到data对象中,以后可以通过module.hot.data访问。如下面的例子用于保存指定模块实例化的时间,从而防止模块更新后数据丢失(刷新后还是会丢失的)。

let moduleStartTime = getCurrentSeconds();
function getCurrentSeconds() {
return Math.round(new Date().getTime() / 1000);
// return new Date().getTime() / 1000;
}
export function getElapsedSeconds() {
return getCurrentSeconds() - moduleStartTime;
}
// Activate Webpack HMR
if (module.hot) {
const data = module.hot.data || {};
// Update our moduleStartTime if we are in the process of reloading
if (data.moduleStartTime)
moduleStartTime = data.moduleStartTime;
// dispose handler to pass our moduleStart time to the next version of our module
// 首次进入我们把当前时间保存到moduleStartTime中以后就可以直接访问
module.hot.dispose((data) => {
data.moduleStartTime = moduleStartTime;
});
}


hotUpdateChunkFilename vs hotUpdateMainFilename

当你修改了test目录下的文件的时候,比如修改了scss文件,此时你会发现在页面中多出了一个script元素,内容如下:

<script type="text/javascript" charset="utf-8" src="0.188304c98f697ecd01b3.hot-update.js"></script>


其中内容是:

webpackHotUpdate(0,{
/***/ 15:
/***/ (function(module, exports, __webpack_require__) {
exports = module.exports = __webpack_require__(46)();
// imports
// module
exports.push([module.i, "html {\n  border: 1px solid yellow;\n  background-color: pink; }\n\nbody {\n  background-color: lightgray;\n  color: black; }\n  body div {\n    font-weight: bold; }\n    body div span {\n      font-weight: normal; }\n", ""]);
// exports

/***/ })
})
//# sourceMappingURL=0.188304c98f697ecd01b3.hot-update.js.map


从内容你也可以看出,只是将我们修改的模块push到exports对象中!而hotUpdateChunkFilename就是为了让你能够执行script的src中的值的!而同样的hotUpdateMainFilename是一个json文件用于指定哪些模块发生了变化,在output目录下。

webpack和webpack-dev-server关系

webpack首先打包成文件放在具体的目录下,并通过publicPath配置成了虚拟路径,而当通过URL访问服务器的时候就会从req.url寻找了具体的输出文件,最后得到这个输出文件并原样发送到客户端。看下面的方法你就明白了:

var pathJoin = require("./PathJoin");
var urlParse = require("url").parse;
function getFilenameFromUrl(publicPath, outputPath, url) {
var filename;
// localPrefix is the folder our bundle should be in
// 第二个参数如果为false那么查询字符串不会被decode或者解析
// 第三个参数为true,那么//foo/bar被解析为{host: 'foo', pathname: '/bar'},也就是第一个"//"后,'/'前解析为host
// 如配置为 publicPath: "/assets/"将会得到下面的结果:
/*
Url {
protocol: null,
slashes: null,
auth: null,
host: null,
port: null,
hostname: null,
hash: null,
search: null,
query: null,
pathname: '/assets/
path: '/assets/',
href: '/assets/'
}
*/
var localPrefix = urlParse(publicPath || "/", false, true);
var urlObject = urlParse(url);
//URL是http请求的真实路径,如http://localhost:1337/hello/world,那么req.url得到的就是/hello/world
// publicPath has the hostname that is not the same as request url's, should fail
// 访问的url的hostname和publicPath中配置的host不一致,直接返回。这只有在publicPath是绝对URL的情况下出现
if(localPrefix.hostname !== null && urlObject.hostname !== null &&
localPrefix.hostname !== urlObject.hostname) {
return false;
}
// publicPath is not in url, so it should fail
// publicPath和req.url必须一样
if(publicPath && localPrefix.hostname === urlObject.hostname && url.indexOf(publicPath) !== 0) {
return false;
}
// strip localPrefix from the start of url
// 如果url中的pathname和publicPath一致,那么请求成功,文件名为urlObject中除去publicPath那一部分的结果
// 如上面/hello/world表示req.url,而且publicPath为/hello/那么得到的文件名就是world
if(urlObject.pathname.indexOf(localPrefix.pathname) === 0) {
filename = urlObject.pathname.substr(localPrefix.pathname.length);
}

if(!urlObject.hostname && localPrefix.hostname &&
url.indexOf(localPrefix.path) !== 0) {
return false;
}
// and if not match, use outputPath as filename
//如果有文件名那么从output.path中获取该文件,文件名为我们获取到的文件名。否则返回我们的outputPath
//也就是说:如果没有filename那么我们直接获取到我们的output.path这个目录
return filename ? pathJoin(outputPath, filename) : outputPath;
}
module.exports = getFilenameFromUrl;


上面这个从URL到路径的转化就是通过webpack-dev-middleware来完成的。

参考资料:

webpack-dev-middlware

wcf编译工具开发
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: