本文来自作者 幻海之巅 在 GitChat 上分享「webpack 菜鸟的学习之路」,「阅读原文」查看交流实录
「文末高能」
编辑 | 樱木花道
对于打包工具的熟悉程度渐渐的也已经成为衡量前端开发工程师水平的一个重要指标。
记得在校招面试的时候就有问各种打包工具的问题,如对于 gulp,grunt,webpack 的熟悉程度,各种打包工具的特点以及优缺点等。
而当我们逐渐融入到一个特定的团队中,一般都有现成的脚手架提供给我们使用,而对于脚手架本身的关注程度也会慢慢降低。
那是否就意味着,我们不需要掌握脚手架的相关知识了呢?其实不然,个人认为有以下几个理由:
(1)任何脚手架都有一定的适用场景,但是同时也有边界,如果你不小心跨域了这个边界,那么你很可能遇到意想不到的问题。
此时,如果你对脚手架的原理有一定的了解,那么也能够更快的定位问题,而不至于当脚手架开发者不在旁边的时候而手忙脚乱。
(2)本着学知识的角度出发,我们也应该或多或少对脚手架有一定的了解
废话不多说,下面我们进入本场 chat 的正题。
其实 webpack 实现 HMR 是依赖于 webpack-dev-server 的,webpack 官方文档也写的非常清楚,我们只需要参考它的做法来完成即可,首先假如我们如下的 webpack.config.js 配置文件:
很显然,我们的入口文件中只有 app.js,同时在 webpack 的 devServer 配置中我们设置了 hot 为 true,而且在 webpack 的 plugin 中我们添加了 new webpack.HotModuleReplacementPlugin() 这个插件。
假如我们需要在 index.js 中实现 HMR,我们写一个 index.js 的代码实例:
import printMe from './print.js'; if(module.hot){ module.hot.accept('./print.js', function() { console.log('Accepting the updated printMe module!');
printMe();
})
}
而我们的print.js为如下内容:
export default function printMe() { console.log('I get called from print.js!');
}
此时,当你修改 print.js 的时候,我们的 index.js 也会重新加载,而且在控制台中也会输出如下内容:
其中 WDS 和 HMR 的输出来源请见后续分析
看看下面的方法你就知道了,在 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)) { //这里是我们自己在webpack.config.js中配置的entry对象,对entry中的每一个入口文件都添加我们webpack/hot/only-dev-server或者webpack/hot/dev-server用于实现HMR
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数组传入函数,由开发者自己构建自己的entry,但是只有在HMR开启的情况下适用
} else {
wpOpt.entry = devClient.concat(wpOpt.entry); //如果用户的entry是数组,那么我们直接将webpack/hot/only-dev-server或者webpack/hot/dev-server传入用于实现HMR
}
});
}
};
请仔细理解上面的注释,因为它蕴含着在HMR模式下,webpack-dev-server对于我们自己配置的 entry 的一种进一步处理。
下面我们将会进一步深入分析 webpack/hot/only-dev-server 和 webpack/hot/dev-server,看看他们是如何实现 HMR 的。
我们来看看 “webpack/hot/only-dev-server” 的文件内容,他是实现 HMR 的关键:
if(module.hot) { var lastHash; var upToDate = function upToDate() { return lastHash.indexOf(__webpack_hash__) >= 0; //(1)如果两个hash相同那么表示没有更新,其中lastHash表示上一次编译的hash,记住是compilation的hash
//只有在HotModuleReplacementPlugin开启的时候存在。任意文件变化后compilation都会发生变化
}; //(2)下面是检查更新的模块
var check = function check() { module.hot.check().then(function(updatedModules) { //(2.1)没有更新的模块直接返回,通知用户无需HMR
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;
} //(2.2)开始更新
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 + ")");
} //(2.2.1)renewedModules表示哪些模块已经更新了
}).then(function(renewedModules) { //(2.2.2)如果有模块没有更新完成,那么继续检查
if(!upToDate()) {
check();
} //(2.2.3)更新的模块updatedModules,renewedModules表示哪些模块已经更新了
require("./log-apply-result")(updatedModules, renewedModules); if(upToDate()) { console.log("[HMR] App is up to date.");
}
});
}).catch(function(err) { //(2.3)更新异常,输出HMR信息
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"); //(3)emitter模块内容,也就是导出一个events实例
/*
var EventEmitter = require("events");
module.exports = new EventEmitter();
*/
hotEmitter.on("webpackHotUpdate", function(currentHash) {
lastHash = currentHash; //(3.1)表示本次更新后得到的hash值
if(!upToDate()) { //(3.1.1)有更新
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) { //(1)renewedModules表示哪些模块需要更新,剩余的模块unacceptedModules表示,哪些模块由于 ignoreDeclined,ignoreUnaccepted配置没有更新
var unacceptedModules = updatedModules.filter(function(moduleId) { return renewedModules && renewedModules.indexOf(moduleId) < 0;
}); //(2)unacceptedModules表示该模块无法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);
});
} //(2)没有模块更新,表示模块是最新的
if(!renewedModules || renewedModules.length === 0) { console.log("[HMR] Nothing hot updated.");
} else { console.log("[HMR] Updated modules:"); //(3)打印那些模块被热更新。每一个moduleId都是数字那么建议使用NamedModulesPlugin(webpack2建议)
renewedModules.forEach(function(moduleId) { console.log("[HMR] - " + moduleId);
}); 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 事件完成,而该事件依赖于 compilation 的 hash 值),其中哪些模块更新成功,而哪些模块由于某种原因没有更新成功。其中没有更新的原因可能是如下的:
至于模块什么时候接受到需要更新是和 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() { //(1)如果开启了HMR模式
if(hot) {
log("info", "[WDS] App hot update..."); var hotEmitter = require("webpack/hot/emitter");
hotEmitter.emit("webpackHotUpdate", currentHash); //重新启动webpack/hot/emitter,同时设置当前hash,通知上面的webpack-dev-server的webpackHotUpdate事件,告诉它打印那些模块的更新信息
if(typeof self !== "undefined" && self.window) { // broadcast update to window
self.postMessage("webpackHotUpdate" + currentHash, "*");
}
} else { //(2)如果不是Hotupdate那么我们直接reload我们的window就可以了
log("info", "[WDS] App updated. Reloading...");
self.location.reload();
}
}
也就是说当客户端(打包到我们的 entry 中的 webpack-dev-server 提供的 websocket 的客户端代码)接受到服务器(webpack-dev-server 接受到 webpack 提供的 compiler 对象可以知道 webpack 什么时候打包完成,通过 webpack-dev-server 提供的 websocket 服务端代码通知 websocket 客户端)发送的 ok 和 warning 信息的时候会要求更新。
如果支持HMR的情况下就会要求检查更新,同时发送过来的还有服务器端本次编译的 compilation 的 hash 值。
如果不支持 HMR,那么我们要求刷新页面。我们继续深入一步,看看服务器什么时候发送'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; //(1)每一个asset都是没有emitted属性,表示没有发生变化。如果发生变化那么这个assets肯定有emitted属性
})
) return this.sockWrite(sockets, "still-ok"); //(1)将stats的hash写给socket客户端
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) { //clientStats表示需要保存stats中的那些属性,可以允许配置,参见webpack官网
this._sendStats(this.sockets, stats.toJson(clientStats)); this._stats = stats;
}.bind(this));
是不是豁然开朗了,也就是每次 compiler 的'done'钩子函数被调用的时候就会要求客户端去检查模块更新,如果客户端不支持 HMR,那么就会全局加载。
整个过程就是 :webpack-dev-server 在用户的入口文件中添加热加载的客户端 websocket 代码=>webpack-dev-server 拿到 webpack 的 compiler 对象 => 判断是否需要更新 => 通过 websocket 服务端代码通知客户端,并发送 compilation 的 hash 值 => 客户端判断 compilation 的 hash 值是否发生变化并实现热加载以及 log 打印*。
而有一点你需要弄清楚,那就是:我们的 webpack-dev-server 必须拿着 webpack 提供的 compiler 对象才行,具体你可以查看我对 webpack-dev-server的一个封装实例。
接下来我们来看看“webpack/hot/dev-server”:
if(module.hot) { var lastHash; //__webpack_hash__是每次编译的hash值是全局的
var upToDate = function upToDate() { return lastHash.indexOf(__webpack_hash__) >= 0;
}; var check = function check() { 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.");
}
两者的主要代码区别在于 check 函数的调用方式:
如果 auApply 设置为 false,那么所有的模块更新都会通过手动调用apply来完成。而所说的被自己 dispose 处理就是通过如下的方式来完成的:
if (module.hot) { module.hot.accept(); //支持热更新 //当前模块代码更新后的回调,常用于移除持久化资源或者清除定时器等操作,如果想传递数据到更新后的模块,可以通过传入data参数,后续参数可以通过module.hot.data获取 module.hot.dispose(() => { window.clearInterval(intervalId); }); }
而一般我们调用 webpack-dev-server 只会添加—hot而已,即内部不需要调用 apply,而传入的都是被 dispose 处理过的模块:
if(devServerOptions.hotOnly)
devClient.push("webpack/hot/only-dev-server"); else if(devServerOptions.hot)
devClient.push("webpack/hot/dev-server");
不管是那种方式,webpack-dev-server 实现热加载都具有如下的流程:
这里就是一个例子,你也可以查看这个仓库https://github.com/liangklfangl/wcf,然后克隆下来,执行下面命令(注意,这个仓库的代码已经发布到 npm 的 webpackcc:
npm install webpackcc -g npm run test
你就会发现访问 localhost:8080 的时候代码是可以支持 HMR(你可以修改 test 目录下的所有的文件),而不会出现页面刷新的情况。下面也给出实例代码:
//time.jslet moduleStartTime = getCurrentSeconds();//(1)得到当前模块加载的时间,是该模块的一个全局变量,首次加载模块的时候获取到,热加载// 时候会重新赋值function getCurrentSeconds() { return Math.round(new Date().getTime() / 1000);
}
export function getElapsedSeconds() { return getCurrentSeconds() - moduleStartTime;
}//(2)开启HMR,如果添加HotModuleReplacement插件,webpack-dev-server添加--hotif (module.hot) { const data = module.hot.data || {}; //(3)如果module.hot.dispose将当前的数据放到了data中可以通过module.hot.data获取
if (data.moduleStartTime)
moduleStartTime = data.moduleStartTime; //(4)我们首次会将当前模块加载的时间传递到热加载后的模块中,从而热加载后的moduleStartTime
// 会一直是首次加载模块的时间
module.hot.dispose((data) => {
data.moduleStartTime = moduleStartTime;
});
}
在 time.js 中我们会在每次热加载的时候保存模块首次加载的时间,这是实现热加载后页面 time 不改变的关键代码。下面再给出 index.js 的代码:
import * as dom from './dom';
import * as time from './time';
import pulse from './pulse';require('./styles.scss');const UPDATE_INTERVAL = 1000; // millisecondsconst intervalId = window.setInterval(() => {
dom.writeTextToElement('upTime', time.getElapsedSeconds() + ' seconds');
dom.writeTextToElement('lastPulse', pulse());
}, UPDATE_INTERVAL);// Activate Webpack HMRif (module.hot) { module.hot.accept(); // dispose handler
module.hot.dispose(() => { window.clearInterval(intervalId);
});
}
你可能有这样的疑问:“如果我们修改 index.js 后,我们页面的时间是否就会刷新呢?”答案是:“不会!”
这是因为:当你改变了 index.js 的代码,虽然我们会调用 clearInterval,但是该模块也是支持热加载的,所以热加载后又会执行 window.setInterval,而我们 time.js 返回的依然是正确的时间。
关于 module.hot.dispose 有一点需要注意:
module.hot.dispose(function(){ console.log('1'); window.clearInterval(intervalId);
})
假如在修改 index.js 之前,我们的代码如上,此时我们修改代码为如下:
module.hot.dispose(function(){ console.log('2'); window.clearInterval(intervalId);
})
此时你会发现打印出来的结果为1而不是2,即打印的结果是HMR完成之前的代码。
这可能是因为这个函数是为了清除持久化资源或者清除定时器等操作而设计的。
完整的代码逻辑你一定要查看这里并运行一下,这样可能更好的了解HMR的逻辑。
2.4.1 accept函数
accept(dependencies: string[], callback: (updatedDependencies) => void) => voidaccept(dependency: string, callback: () => void) => void
此时表示,我们这个模块支持 HMR,任何其依赖的模块变化都会被捕捉到。当依赖的模块更新后回调函数被调用。当然,如果是下面这种方式:
accept([errHandler]) => void
那么表示我们接受当前模块所有依赖的模块的代码更新,而且这种更新不会冒泡到父级中去。这当我们模块没有导出任何东西的情况下有用(因为没有导出,所以也就没有父级调用)。
2.4.2 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[]) => voiddecline(dependency: string) => void
这表明我们不会接受特定模块的更新,如果该模块更新了,那么更新失败同时失败代码为“decline”。而上面的代码表明我们不会接受 jquery 模块的更新。当然也可以是如下模式:
decline() => void
这表明我们当前的模块是不会更新的,也就是不会 HMR。如果更新了那么错误代码为“decline”。
2.4.3 其中 dispose 函数
函数签名如下:
dispose(callback: (data: object) => void) => voidaddDisposeHandler(callback: (data: object) => void) => void
这表示我们会添加一个一次性的处理函数,这个函数在当前模块更新后会被调用。
此时,你需要移除或者销毁一些持久的资源,如果你想将当前的状态信息转移到更新后的模块中,此时可以添加到 data 对象中,以后可以通过 module.hot.data 访问。
如下面的 time.js 例子用于保存指定模块实例化的时间,从而防止模块更新后数据丢失(刷新后还是会丢失的)。
2.4.4 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// moduleexports.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 目录下。
要实现 less/scss/css 的热加载是非常容易的,我们可以直接使用 style-loader 来完成(在开发模式下,生产模式下不建议使用)。比如在开发模式下对于 css 的加载可以配置如下的 loader:
对于 style-loader 热加载的你可以直接点击这里,其中原理上面都说过了,如果不懂,请仔细阅读上面的 HMR 的部分。
而至于 less/scss 因为最终都会打包成为css,所以其实和 css 的热加载是一样的道理。
我这里说的 webpack 的 watch 模式指的是:“启动 webpack 打包命令后,当文件发生改变 webpack 会自动重新打包”。
上面我说过,webpack-dev-server 可以拿到 webpack 的 compiler 对象,通过该对象可以明确的知道当前 webpack 打包所处的阶段,包括知道打包是否完成等。
其实 webpack 本身启动以后就给我们提供了这个对象,而如果要获取到这个对象,通过下面的代码就可以完成:
//其中defaultWebpackConfig就是webpack打包的配置文件const compiler = webpack(defaultWebpackConfig);
compiler.run(function doneHandler(err, stats) { //get all errors
if(stats.hasErrors()){ //打印错误信息
printErrors(stats.compilation.errors,true);
} const warnings =stats.warnings && stats.warnings.length==0; if(stats.hasWarnings()){ //打印warning信息
printErrors(stats.compilation.warnings);
} console.log("Compilation finished!\n");
});
当然,如果你只需要完成一次性打包,调用上面的 watch 方法就可以了。如果你需要监听打包文件的变化,那么可以使用 compiler 给我们暴露的 watch 方法,其中调用方式如下:
compiler.watch(delay, callback);//其中第一个参数就是监听间隔的时间,而callback和上面的doneHandler是一样的
在这里我们对 webpack 的 watch 模式与一次性打包模式就做了简单的区分。如果你需要监听其他文件的变化,那么还可以通过 chokidar 来完成。
比如下面的例子就是如果 webpack 的配置文件发生变化后,直接退出程序:
//customWebpackPath表示自己的webpack配置文件路径
chokidar.watch(customWebpackPath).on('change',function(){
onsole.log('You must restart to compile because configuration file changed!');
process.exit(0); //We must exit because configuration file changed!
});
这部分内容,其实官方webpack的API都已经说了,当然你可以查看我是如何实现webpack的watch模式的。
官方宣称 Prepack 是一个优化 JavaScript 源代码的工具,实际上它是一个 JavaScript 的部分求值器(Partial Evaluator),可在编译时执行原本在运行时的计算过程,并通过重写 JavaScript 代码来提高其执行效率。
Prepack用简单的赋值序列来等效替换 JavaScript 代码包中的全局代码,从而消除了中间计算过程以及对象分配的操作。
对于重初始化的代码,Prepack 可以有效缓存 JavaScript 解析的结果,优化效果最佳。我们下面来根据一个例子说明 prepack 干了什么?
假如我们一开始的时候生成了如下的 ES5 代码:
(function () { function hello() { return 'hello'; } function world() { return 'world'; }
global.s = hello() + ' ' + world();
})();
那么经过 prepack 的处理,下面的代码就会变成:
(function () {
s = "hello world";
})();
代码的转化也体现了“部分求值器”的概念,而这求值的过程其实是在编译的时候完成的,而不用等到 javascript 真实运行的时候。
其实 prepack 到底做了什么事情,你可以查看 prepack 的官方文档。我今天要说的是 prepack 是如何和 webapck 结合起来的?
关于 prepack 与 webpack 区别的文章我很久以前就写过,一开始我也纠结于两者的不同,直到我分析了prepack-webpack-plugin这个插件我才真正明白过来,原来 prepack 和 webpack 的联系是可以通过插件结合起来的,他们两者本来是完全不同的工具。
webpack 关注于如何对 ES6 进行打包,而 prepack 的作用是将 ES5 代码进行进一步的优化,这也是为什么可以通过插件将 webpack 打包的 ES6 代码进一步经过 prepack 进行处理。
下面是如何在 webpack 中集成 prepack 的功能:
import PrepackWebpackPlugin from 'prepack-webpack-plugin';const configuration = {};module.exports = { // ...
plugins: [ new PrepackWebpackPlugin(configuration)
]
};
下面我们继续看看 prepack-webpack-plugin 的关键代码:
apply (compiler: Object) { const configuration = this.configuration;
compiler.plugin('compilation', (compilation) => {
compilation.plugin('optimize-chunk-assets', (chunks, callback) => { for (const chunk of chunks) { const files = chunk.files; //chunk.files获取该chunk产生的所有的输出文件,记住是输出文件
for (const file of files) { const matchObjectConfiguration = {
test: configuration.test
}; if (!ModuleFilenameHelpers.matchObject(matchObjectConfiguration, file)) { // eslint-disable-next-line no-continue
continue;
} const asset = compilation.assets[file]; const code = asset.source(); //(1)获取webpack本身打包后的ES5代码
const prepackedCode = prepack(code, { //(2)prepack对webpack打包后的代码进行进一步处理
...configuration.prepack,
filename: file
}); //(3)生成新的对webpack打包后的ES5代码进一步处理后的结果
compilation.assets[file] = new RawSource(prepackedCode.code);
}
}
callback();
});
});
}
其中 webpack 的插件主要代码都是集成在 apply 方法中,你明白了注释中的三步基本上就明白了 prepack 与 webpack 的关系了。
关于 webpack-dev-server 的基础知识我很久以前也做过分析,目前也会随着自己的理解对这部分内容进行更新。但是,最容易让人混淆的就是以下几个知识点:
webpack-dev-server 会使用当前的路径作为请求的资源路径(所谓当前的路径就是你运行 webpack-dev-server 这个命令的路径,如果你对 webpack-dev-server 进行了包装,比如 wcf,那么当前路径指的就是运行 wcf 命令的路径,一般是项目的根路径),但是你可以通过指定 content base 来修改这个默认行为:
$ webpack-dev-server --content-base build/
这样 webpack-dev-server 就会使用build目录下的资源来处理静态资源的请求,比如css/图片等。
content-base 一般不要和 publicPath,output.path 混淆掉。其中 content-base 表示静态资源的路径是什么,比如下面的例子:
<!DOCTYPE html><html><head>
<title></title>
<link rel="stylesheet" type="text/css" href="index.css"></head><body>
<div id="react-content">这里要插入js内容</div></body></html>
在作为 html-webpack-plugin 的 template 以后,那么上面的 index.css 路径到底是什么?是相对于谁来说?上面我已经强调了:如果在没有指定 content-base 的情况下就是相对于当前路径来说的,所谓的当前路径就是在运行 webpack-dev-server 目录来说的。
所以假如你在项目根路径运行了这个命令,那么你就要保证在项目根路径下存在该 index.css 资源,否则就会存在 html-webpack-plugin 的404报错。
当然,为了解决这个问题,你可以将 content-base 修改为和 html-webpack-plugin的html 模板一样的目录。
上面讲到 content-base 只是和静态资源的请求有关,那么我们将其和 publicPath 和 output.path 做一个区分:
首先:假如你将 output.path 设置为 build(这里的 build 和 content-base 的 build 没有任何关系,请不要混淆),你要知道 webpack-dev-server 实际上并没有将这些打包好的 bundle 写到这个目录下,而是存在于内存中的,但是我们可以假设(注意这里是假设)其是写到这个目录下的。
然后:这些打包好的bundle在被请求的时候,其路径是相对于你配置的 publicPath 来说的,因为我的理解publicPath相当于虚拟路径,其映射于你指定的output.path。
假如你指定的 publicPath 为 “/assets/”,而且 output.path 为“build”,那么相当于虚拟路径“/assets/”对应于“build”(前者和后者指向的是同一个位置),而如果build下有一个“index.css”,那么通过虚拟路径访问就是/assets/index.css。
最后:如果某一个内存路径(文件写在内存中)已经存在特定的 bundle,而且编译后内存中有新的资源,那么我们也会使用新的内存中的资源来处理该请求,而不是使用旧的 bundle! 比如我们有一个如下的配置:
module.exports = {
entry: {
app: ["./app/main.js"]
},
output: {
path: path.resolve(__dirname, "build"),
publicPath: "/assets/", //此时相当于/assets/路径对应于build目录,是一个映射的关系
filename: "bundle.js"
}
}
那么我们要访问编译后的资源可以通过 localhost:8080/assets/bundle.js 来访问。
如果我们在 build 目录下有一个 html 文件,那么我们可以使用下面的方式来访问js资源。
<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8">
<title>Document</title></head><body>
<script src="assets/bundle.js"></script></body></html>
此时你会看到控制台输出如下内容:
主要关注下面两句输出:
之所以是这样的输出结果是因为我们设置了 contentBase 为 build,因为我们运行的命令为 webpack-dev-server --content-base build/。
所以,一般情况下:如果在html模板中不存在对外部相对资源的引用,我们并不需要指定 content-base,但是如果存在对外部相对资源css/图片的引用,我们可以通过指定 content-base 来设置默认静态资源加载的路径,除非你所有的静态资源全部在当前目录下。
但是,在wcf中,如果你指定的 htmlTemplate,那么我会默认将 content-base 设置为 htmlTemplate 同样的路径,所以在 htmlTemplate 中你可以随意使用相对路径引用外部的css/图片。
我们看看 webpack-dev-server 中是如何处理的:
contentBaseFiles: function() { //如果contentBase是数组
if(Array.isArray(contentBase)) {
contentBase.forEach(function(item) {
app.get("*", express.static(item));
}); //如果contentBase是https/http的路径,那么重定向
} else if(/^(https?:)?\/\//.test(contentBase)) { console.log("Using a URL as contentBase is deprecated and will be removed in the next major version. Please use the proxy option instead."); console.log('proxy: {\n\t"*": "<your current contentBase configuration>"\n}'); // eslint-disable-line quotes
// Redirect every request to contentBase
app.get("*", function(req, res) {
res.writeHead(302, { "Location": contentBase + req.path + (req._parsedUrl.search || "")
});
res.end();
});
} else if(typeof contentBase === "number") { console.log("Using a number as contentBase is deprecated and will be removed in the next major version. Please use the proxy option instead."); console.log('proxy: {\n\t"*": "//localhost:<your current contentBase configuration>"\n}'); // eslint-disable-line quotes
// Redirect every request to the port contentBase
app.get("*", function(req, res) {
res.writeHead(302, { "Location": `//localhost:${contentBase}${req.path}${req._parsedUrl.search || ""}`
});
res.end();
});
} else { // route content request
// http://www.expressjs.com.cn/starter/static-files.html
// 把静态文件的目录传递给static那么以后就可以直接访问了
app.get("*", express.static(contentBase, options.staticOptions));
}
}
此处不解释,因为其调用的就是 express.static 方法,主要用于请求静态资源。注意 webpack 官网的说明:
也就是说这个配置只有在命令行中有用,而不能直接传入到 webpack.config.js 中产生作用!
同时这个配置也会影响 serve-index 的作用:
contentBaseIndex: function() { if(Array.isArray(contentBase)) {
contentBase.forEach(function(item) {
app.get("*", serveIndex(item)); //The path is based off the req.url value, so a req.url of '/some/dir with a path of 'public' will look at 'public/some/dir'
//其中这里的path表示我们的contentBase,所以我们的请求都是在contentBase下寻找
});
} else if(!/^(https?:)?\/\//.test(contentBase) && typeof contentBase !== "number") {
app.get("*", serveIndex(contentBase));
}
}
注意:在webpack2中—content-base在webpack.config.js中配置也是可以生效的,建议使用一下我上面的wcf打包工具。
其实 webpack-common-chunk-plugin 的原理我以前做个详细的分析。而且以可视化的方式进行了深入讲解,此处我不会对它做进一步的深入。如果你有问题也欢迎 issue 共同讨论。
但是我这里会对另外一部分内容做一下讲解,即 webpack 如何利用拓扑结构来判断某一个模块被依赖的次数。
'use strict';var toposort = require('toposort');var _ = require('lodash');module.exports.dependency = function (chunks) { if (!chunks) { return chunks;
} //(1)构建chunk-id -> chunk这种Map结构更加容易绘制图
var nodeMap = {};
chunks.forEach(function (chunk) {
nodeMap[chunk.id] = chunk;
}); var edges = [];
chunks.forEach(function (chunk) { if (chunk.parents) { //(1)添加parent->child一条边
chunk.parents.forEach(function (parentId) { var parentChunk = _.isObject(parentId) ? parentId : nodeMap[parentId]; if (parentChunk) {
edges.push([parentChunk, chunk]);
}
});
}
}); return toposort.array(chunks, edges);
};
其中最重要的就是上面引入的 toposort,上面的代码就是为了构建一个 toposort 需要的数据而已。我们给出 toposort 的一个例子:
// First, we define our edges.var graph = [
['put on your shoes', 'tie your shoes']
, ['put on your shirt', 'put on your jacket']
, ['put on your shorts', 'put on your jacket']
, ['put on your shorts', 'put on your shoes']
]// Now, sort the vertices topologically, to reveal a legal execution order.toposort(graph)// [ 'put on your shirt'// , 'put on your shorts'// , 'put on your jacket'// , 'put on your shoes'// , 'tie your shoes' ]
此时你将会得到下面的图形:
webpack-common-chunk-plugin 就是通过这个 toposort 来判断每一个模块被依赖的次数,进而将符合一定次数的模块单独提取出来放到common.js(可以自由命名)中。
关于 react-router+webpack 实现按需加载我也写了一个完整的例子,你可以查看:https://github.com/liangklfangl/webpack-code-splitting。因为本文已经太长,所以此处不再贴出代码。
但是我要说的是,webapck 实现“code splitting”需要通过 System.import 或者 require.ensure,下面是通过前者来实现按需加载的:
{
path: 'about',
getComponent(location, cb) {
System.import('./components/About')
.then(loadRoute(cb))
.catch(errorLoading);
},
},
此时,当你的路由满足“/about”的时候就会动态加载 components/About 的组件。关于 react-router 的更多内容你可以查看:https://github.com/liangklfangl/react-article-bucket/tree/master/react-router。
通过我上面分享的知识点自己写一个单页面打包工具应该不难。上面我总共提到了三种打包模式:webpack 一次性打包 + webpack 的 watch 模式 + webpack-dev-server 的模式。
其中对于三者的打包原理和实现都做了深入的分析,特别是 webpack-dev-server 的模式,详细论述了其对于 HMR 的支持。如果你有不懂的地方,可以再重头到尾读一遍。
其中我下面介绍的自己写的一个脚手架就是对上面三种模式的封装,也就是我反复提到的 wcf。你可以自己查看他的源码来进一步回顾和深入理解我上面提到的知识点。
因为本场 Chat 牵涉的知识点比较多,因此文章内容也比较长,Chat 交流的问题请点击「阅读原文」查看实录,文中提到的很多内容我都单独放在自己的 git 仓库中,你可以简单的克隆并运行。
而且文中对于很多知识点的分析都是以代码的形式来完成的,如果有不对的地方,也欢迎大家指出。
彩蛋
让真正的前端牛人带你采坑
公众号内回复「大漠穷秋」
快速上手 Angular
「阅读原文」了解更多知识