如今,已经有不少人分析过 webpack
热更新的文章了。javascript
那么,为何还要写本篇文章呢?html
主要是,我想从源码分析的角度去梳理一下。前端
webpack
热更新的总体流程比较复杂,第一次接触的同窗很容易陷入到webpack-dev-server
/ webpack-client
这些名字的深渊中,但愿这篇文章会对你有帮助。java
通常来讲,咱们跑起来一个前端项目的命令是:node
npm run start
复制代码
那么,咱们找到这个项目的package.json
文件,能够找到下面这段代码:webpack
"scripts": {
"start": "webpack-dev-server --hot --open"
},
复制代码
上面的代码意思是,使用 webpack-dev-server
这个命令,传入hot
、open
两个参数。git
webpack-dev-server
命令是从哪里来的呢?咱们在 ./node_module
目录下面,找到 webpack-dev-server
包,找到这个包的 package.json
文件,这个文件描述了 这个 npm
包的行为,有一个字段是 bin
,以下面:github
"bin": "bin/webpack-dev-server.js",
复制代码
上面代码的意思是,当咱们执行 webpack-dev-server
命令时,本质是执行了node_module/webpack-dev-server/bin/webpack-dev-server.js
这个文件。web
webpack-dev-server.js
webpack-dev-server.js
作了什么呢?ajax
主要是作了两件事情:
引用 webpack
,而后开始编译,实例化出一个 compiler
,好比说var compiler = webpack(options)
启动服务,而且刚才实例化出来的 compiler
,传入到服务中。好比说new Server(compiler)
。
下面的代码,就是简化版的 webpack-dev-server.js
的内容
// bin/webpack-dev-server.js 的内容
// 1. 调用 webpack 开始编译
let compiler;
try {
compiler = webpack(webpackOptions);
} catch (e) {
throw e;
}
// 2. 启动服务
const Server = require('../lib/Server');
try {
server = new Server(compiler, options);
} catch (e) {
process.exit(1);
}
复制代码
细心的同窗会发现,new Server()
里面其实传入了 compiler
对象。compiler
对象表明着 webpack
编译过程, 咱们就能够在服务端的拿到编译各个过程的钩子。
调用 webpack()
方法,咱们亲爱的webpack
会编译打包咱们的代码到内存中。具体 webpack
的编译打包过程,这里就不讲解了,内容有点多,之后有机会再说。
接下来,咱们把注意力转移到本文的重点,代码热更新上。也就是, new Server()
背后作了什么。
new Server()
作了什么事情呢?其实就是三件事情: 创建了http
的静态资源服务 、创建了 websocket
服务、监听了 webpack 从新编译的 done
的生命周期。
http
的静态资源服务的做用是什么呢?
做用是:提供打包后的 js
资源。当开发时候,在浏览器里面请求 http://localhost:8081/bundle.js
的静态资源,就能够拿到对应的js文件。这是由于中间件 webpack-dev-middleware
使用了 express
框架搭建了一个静态资源的服务。咱们后面会讲解到。
websocket
服务的做用是什么呢?
做用是:用于通知浏览器。这里的服务端,并非远程的服务端,而是跑在咱们本机上的服务。服务端没有办法经过http
协议去通知浏览器,『嘿,你须要更新了』,只能经过 websocket
协议去通知浏览器。
接下来,咱们就来看一下,是如何创建http
的静态资源服务的?
首先是,引用了 express
框架,起了一个后端服务;
const express = require('express');
const app = this.app = new express();
复制代码
可是,光跑起来服务还不行,咱们还要匹配对应的路径,返回不一样的静态资源;
其次,使用中间件 webpack-dev-middleware
,匹配对应的路径。
下面这一段代码,本质就是提供了普通的静态资源服务器的基本功能。做用是,根据客户端请求的路径,返回不一样的文件内容。
惟一不一样的地方是,文件内容是从内存中读出的,由于访问内存中的代码比访问文件系统中的文件更快,并且也减小了代码写入文件的开销,这一切都归功于memory-fs。
function processRequest() {
try {
var stat = context.fs.statSync(filename);
if(!stat.isFile()) {
// 是目录
if(stat.isDirectory()) {
// 若是是访问 localhost:8080, index 就是 undefined
var index = context.options.index;
if(index === undefined || index === true) {
// 默认是 index.html
index = "index.html";
} else if(!index) {
throw "next";
}
// 找到了
filename = pathJoin(filename, index);
stat = context.fs.statSync(filename);
// 若是不是文件,则报错退出
if(!stat.isFile()) throw "next";
} else {
throw "next";
}
}
} catch(e) {
return resolve(goNext());
}
// 从内存中找到 "/Users/dudu/webstorm/beibei/webpack-HMR-demo//bundle.js"
// 从内存中,读出 /bundle.js 的二进制数据
var content = context.fs.readFileSync(filename);
content = shared.handleRangeHeaders(content, req, res);
// 肯定响应的contentType
var contentType = mime.lookup(filename);
if(!/\.wasm$/.test(filename)) {
contentType += "; charset=UTF-8";
}
// 设置 Content-Type Content-Length
res.setHeader("Content-Type", contentType);
res.setHeader("Content-Length", content.length);
if(context.options.headers) {
for(var name in context.options.headers) {
res.setHeader(name, context.options.headers[name]);
}
}
// Express automatically sets the statusCode to 200, but not all servers do (Koa).
// 做者彷佛黑了一把 Koa
res.statusCode = res.statusCode || 200;
if(res.send) res.send(content);
else res.end(content);
resolve();
}
复制代码
咱们用 webpack-dev-server 相对简单,直接安装依赖后执行命令便可,用 webpack-dev-middleware
能够在既有的 Express 代码基础上快速添加 webpack-dev-server
的功能,同时利用 Express 来根据须要添加更多的功能,如 mock 服务、代理 API 请求等。
使用 sockjs
库,创建一个 websocket
的服务。
// 创建一个 `websocket ` 的服务
const sockjs = require('sockjs');
const sockServer = sockjs.createServer();
复制代码
可是,光有 websocket
的服务还不行啊,还得有客户端的请求啊,咱们并无写接收 websocket
消息的代码。
当咱们使用了 webpackd-dev-server
, 就会修改了webpack 配置中的 entry 属性,在里面添加了创建 websocket
链接的代码,这样在最后的 bundle.js
文件中就会有接收 websocket
消息的代码了。
done
钩子其实,本质就是监听 compiler.done
的生命周期
上文中说过,new Server(compiler)
传入了 compiler
对象,compiler
对象表明着webpack编译过程, 就能够拿到编译各个过程的钩子。
下面的代码,是监听了 done
的生命周期函数,若是文件发生了变化,webpack 发生了从新编译,在done
的钩子中, 调用 _sendStats
方法,使用 websocket
协议去通知浏览器。
// webpack-dev-server/lib/Server.js
compiler.plugin('done', (stats) => {
// stats.hash 是最新打包文件的 hash 值
this._sendStats(this.sockets, stats.toJson(clientStats));
this._stats = stats;
});
Server.prototype._sendStats = function (sockets, stats, force) {
// 调用 sockWrite 方法将 hash 值经过 websocket 发送到浏览器端
this.sockWrite(sockets, 'hash', stats.hash);
};
复制代码
接下来,咱们把视角转到浏览器中。
浏览器会接收 websocket
消息, 这部分的代码,是被打到 bundle.js
里面的。
源码是 node_modules/_webpack-dev-server@2.11.5@webpack-dev-server/client/index.js
咱们来看一下:
var socket = function initSocket(url, handlers) {
sock = new SockJS(url);
sock.onopen = function onopen() {
retries = 0;
};
sock.onclose = function onclose() {
};
// 在这里,接收 来自 webpack-dev-server 的各类消息
sock.onmessage = function onmessage(e) {
var msg = JSON.parse(e.data);
// 根据 服务端传过来的 type, 调用不一样的处理函数
if (handlers[msg.type]) {
handlers[msg.type](msg.data);
}
};
};
复制代码
上面处理函数的集合 handlers
长什么样子呢?
var onSocketMsg = {
hot: function hot() {},
invalid: function invalid() {},
hash: function hash(_hash) {
currentHash = _hash;
},
'still-ok': function stillOk() {},
'log-level': function logLevel(level) {},
overlay: function overlay(value) {},
progress: function progress(_progress) {},
'progress-update': function progressUpdate(data) {},
ok: function ok() {
sendMsg('Ok');
reloadApp();
},
'content-changed': function contentChanged() {
self.location.reload();
},
warnings: function warnings(_warnings) {},
errors: function errors(_errors) {},
error: function error(_error) {},
close: function close() {}
};
复制代码
咱们能够发现,服务器传给浏览器的 websocket 消息有好多类型哦,
可是咱们只须要关注 hash
类型 和 ok
类型:
// webpack-dev-server/client/index.js
hash: function msgHash(hash) {
currentHash = hash;
},
ok: function msgOk() {
reloadApp();
},
function reloadApp() {
if (hot) {
log.info('[WDS] App hot update...');
const hotEmitter = require('webpack/hot/emitter');
hotEmitter.emit('webpackHotUpdate', currentHash);
} else {
log.info('[WDS] App updated. Reloading...');
self.location.reload();
}
}
复制代码
如上面代码所示,首先将 hash 值暂存到 currentHash
变量,当接收到 ok
类型消息后,对 App 进行 reload
。
判断是否配置了模块热更新hot
,若是没有配置模块热更新,就直接调用 location.reload 方法刷新页面。若是配置了,就调用hotEmitter.emit('webpackHotUpdate', currentHash)
接下来发生了什么呢?
// 监听第三步 webpack-dev-server/client 发送的 webpackHotUpdate 消息
// 调用了 check() 方法
hotEmitter.on("webpackHotUpdate", function(currentHash) {
lastHash = currentHash;
// 居然去执行了检查,真是严谨的小 webpack 啊
check();
});
复制代码
执行了 check()
过程,那么到底是怎么检查是否须要更新的呢?
在 check
过程当中会利用两个方法: hotDownloadManifest
和 hotDownloadUpdateChunk
// 调用 AJAX 向服务端请求是否有更新的文件,若是有将发更新的文件列表返回浏览器端
hotDownloadManifest(hotRequestTimeout).then(function(update) {
if(!update) {
hotSetStatus("idle");
return null;
}
hotRequestedFilesMap = {};
hotWaitingFilesMap = {};
hotAvailableFilesMap = update.c;
hotUpdateNewHash = update.h;
hotSetStatus("prepare");
var promise = new Promise(function(resolve, reject) {
hotDeferred = {
resolve: resolve,
reject: reject
};
});
hotUpdate = {};
var chunkId = 0;
hotEnsureUpdateChunk()
if(hotStatus === "prepare" && hotChunksLoading === 0 && hotWaitingFiles === 0) {
// 真正开始下载了
hotUpdateDownloaded();
}
return promise;
});
复制代码
上面代码的核心就是:
先检查是否须要更新,再下载更新的文件
return hotDownloadManifest().then(function() {
hotEnsureUpdateChunk()
hotUpdateDownloaded();
});
复制代码
咱们先来看一下 hotDownloadManifest
方法作了什么:
// webpack本身写了一个原生ajax
function hotDownloadManifest(requestTimeout) {
requestTimeout = requestTimeout || 10000;
return new Promise(function(resolve, reject) {
if(typeof XMLHttpRequest === "undefined")
return reject(new Error("No browser support"));
try {
// 构建了一个原生的 XHR 对象
var request = new XMLHttpRequest();
// 拼接请求的路径
// hotCurrentHash 是 89e94c7776606408e5a3
// requestPath 是 "89e94c7776606408e5a3.hot-update.json"
var requestPath = __webpack_require__.p + "" + hotCurrentHash + ".hot-update.json";
//
request.open("GET", requestPath, true);
request.timeout = requestTimeout;
request.send(null);
} catch(err) {
return reject(err);
}
request.onreadystatechange = function() {
// success
try {
// update 是 "{"h":"3f8a14b8d23b5bad41cc","c":{"0":true}}"
var update = JSON.parse(request.responseText);
} catch(e) {
reject(e);
return;
}
};
});
}
复制代码
上面的代码,核心流程是,构造一个原生的 XHR 对象, 向服务端请求是否有更新的文件,发送的GET
请求的路径是相似于 "89e94c7776606408e5a3.hot-update.json" 这样的。中间一串数字是 hash 值。
服务端若是有将发更新的文件列表返回浏览器端,拿到的文件列表大概是这样的
"{"h":"3f8a14b8d23b5bad41cc","c":{"0":true}}"
复制代码
接下来,执行 hotEnsureUpdateChunk
方法,以下面代码所示。最终实际上是执行了 hotDownloadUpdateChunk
方法。
function hotEnsureUpdateChunk(chunkId) {
if(!hotAvailableFilesMap[chunkId]) {
hotWaitingFilesMap[chunkId] = true;
} else {
hotRequestedFilesMap[chunkId] = true;
hotWaitingFiles++;
hotDownloadUpdateChunk(chunkId);
}
}
复制代码
hotDownloadUpdateChunk
方法的核心就是使用 jsonp
去下载更新后的代码:
function hotDownloadUpdateChunk(chunkId) {
var head = document.getElementsByTagName("head")[0];
var script = document.createElement("script");
script.type = "text/javascript";
script.charset = "utf-8";
script.src = __webpack_require__.p + "" + chunkId + "." + hotCurrentHash + ".hot-update.js";
;
head.appendChild(script);
}
复制代码
上面的代码中,所谓的jsonp
技术,其实没什么难度。
核心就是建立一个 <script>
标签,设置好type
、src
属性,浏览器就去下载 js 脚本。由于浏览器在下载脚本的时候,不会进行跨域处理,因此 jsonp
也经常用于处理跨域。
<script>
标签的 src
属性指定了 js 脚本文件的路径,是由 chunkId
和 hotCurrentHash
拼接起来的:
// 大概长这个样子
"http://localhost:8080/0.4d6b38763300df57f063.hot-update.js"
复制代码
而后,浏览器就回去下载这个文件:
下载回来的文件大概是长这个样子的:
webpackHotUpdate(0,{
/***/ 28:
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
const hello = () => 'hell12o world nice12333'
/* harmony default export */ __webpack_exports__["a"] = (hello);
/***/ })
})
复制代码
到目前为止,咱们经历了的流程是:
文件更新
-> webpack 从新编译打包
-> 监听到 编译打包的 done 阶段
-> 发送socket 通知浏览器
-> 浏览器收到通知
-> 发送 ajax 请求检查
-> 若是确实须要更新,发送 jsonp 请求拉取新代码
-> jsonp 请求回来的代码能够直接执行
咱们接着来看,接下来发生了什么?
接下来,咱们要更新的代码已经下载下来了。咱们应该怎么样,在保持页面状态的状况下,把新的代码插进去。
咱们注意到,下载的文件中,调用了 webpackHotUpdate
方法。这个方法的定义以下:
// `webpackHotUpdate` 方法 的定义
var parentHotUpdateCallback = window["webpackHotUpdate"];
window["webpackHotUpdate"] =
function webpackHotUpdateCallback(chunkId, moreModules) { // eslint-disable-line no-unused-vars
hotAddUpdateChunk(chunkId, moreModules);
if(parentHotUpdateCallback) parentHotUpdateCallback(chunkId, moreModules);
} ;
复制代码
上面代码的核心,是调用了 hotAddUpdateChunk
方法, hotAddUpdateChunk
方法又调用了 hotUpdateDownloaded
方法,又调用了 hotApply
方法。
这一步是整个模块热更新(HMR)的关键步骤,这儿我不打算把 hotApply 方法整个源码贴出来了,由于这个方法包含 300 多行代码,我将只摘取关键代码片断:
// webpack/lib/HotModuleReplacement.runtime
function hotApply() {
// ...
var idx;
var queue = outdatedModules.slice();
while(queue.length > 0) {
moduleId = queue.pop();
module = installedModules[moduleId];
// ...
// remove module from cache
delete installedModules[moduleId];
// when disposing there is no need to call dispose handler
delete outdatedDependencies[moduleId];
// remove "parents" references from all children
for(j = 0; j < module.children.length; j++) {
var child = installedModules[module.children[j]];
if(!child) continue;
idx = child.parents.indexOf(moduleId);
if(idx >= 0) {
child.parents.splice(idx, 1);
}
}
}
// ...
// insert new code
for(moduleId in appliedUpdate) {
if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
modules[moduleId] = appliedUpdate[moduleId];
}
}
// ...
}
复制代码
从上面 hotApply
方法能够看出,模块热替换主要分三个阶段
第一个阶段是找出 outdatedModules
和 outdatedDependencie
第二个阶段从缓存中删除过时的模块和依赖,以下:
delete installedModules[moduleId];
delete outdatedDependencies[moduleId];
复制代码
可是,咱们还剩最后一件事情,当用新的模块代码替换老的模块后,可是业务代码并不能知道代码已经发生变化,虽然新代码已经被替换上去了,可是并无被真正执行一遍。
接下来,就是热更新的阶段。
不知道你有没有听过或看过这样一段话:“在高速公路上将汽车引擎换成波音747飞机引擎”。
微信小程序的开发工具,没有提供相似 Webpack 热更新的机制,因此在本地开发时,每次修改了代码,预览页面都会刷新,因而以前的路由跳转状态、表单中填入的数据,都没了。
若是有相似 Webpack 热更新的机制存在,则是修改了代码,不会致使刷新,而是保留现有的数据状态,只将模块进行更新替换。也就是说,既保留了现有的数据状态,又能看到代码修改后的变化。
webpack 具体是如何实现呢?
须要 咱们开发者,本身去写一些额外的代码。
这些额外的代码,告诉 webpack
要么是接受变动(页面不用刷新,模块替换下就好),要么不接受(必须得刷新)。咱们须要手动在业务代码里面这样写相似于下面
if (module.hot) {
// 选择接受并处理 timer 的更新, 若是 timer.js 更新了,不刷新浏览器更新
module.hot.accept('timer', () => {
// ...
})
// 若是 foo.js 更新了,须要刷新浏览器
module.hot.decline('./foo')
}
复制代码
这些额外的代码放在哪里呢?
假设 index.js
引用了 a.js
。那么,这些额外的代码要么放在 index.js
,要么放在 a.js
中。
Webpack 的实现机制有点相似 DOM 事件的冒泡机制,更新事件先由模块自身处理,若是模块自身没有任何声明,才会向上冒泡,检查使用方是否有对该模块更新的声明,以此类推。若是最终入口模块也没有任何声明,那么就刷新页面了。
关于这块内容,能够看 这一篇文章的讲解 juejin.im/post/5c14be…
这样就是整个 HMR 的工做流程了。