第一部分:基础概念
一、效率瓶颈
我们首先得知道为什么出现了热更新这么一个东西,知其然也要知其所以然,简单来说就是效率的瓶颈。
前端开发如何提升效率?也就是说单位时间内产出的产品更多,质量更好!
当然可以通过学习和培训提高开发人员的编程能力,一个优秀的编程人员效率会比一个普通的编程人员产出更高,质量更好。但是我们都知道成年人只适合淘汰不适合教育,所以这种方式成本过高。
那么其他方式呢!
前端开发的产品是直接面对用户的,基于这个特性,当我们在开发产品的时候,如果对源代码进行了修改,需要立马能够看到这部分的修改对界面造成的影响,从而得到界面上的反馈来继续优化我们的代码,不断重复这个过程,以便调试和优化代码,直至界面达到我们的预期。
所以前端开发大量的时间都是在得到反馈的这个时间中浪费掉的,如果能够尽可能减少这部分的时间的浪费,开发体验和开发效率都会高不少!
我们都知道为了更好的模拟生产环境,开发时我们都会启动一个本地服务来构建一个简单的BS(browser/client)架构。他们的工作流就像下面这个样子。
当我们修改了源代码之后,服务器是有资源的变化,也能感知到资源的变化,但是客户端并不知道资源变化了,所以还是原来未改变的代码的样子。因此在常规情况下,需要手动的刷新一下页面去请求最新的资源。这无疑降低了开发的效率。
二、如何解决
其实主要就是在源代码发生改变的时候,能够通知客户端,我发生改变了,你也需要重新请求一下资源。
这个过程涉及到服务端主动向客户端发送消息,所以我们需要利用WebSocket来进行全双工的通信。
理想的效果就是当我们的源代码发生更新了,使用socket通知客户端刷新页面,不用程序员手动自己刷新,这就节省了很多时间,增加了开发效率。流程图如下图所示:
整个过程如果用文字描述就是这样的:
第一步:服务端提供一个静态服务,源代码放在静态服务里
第二步:服务端提供一个socket服务,用于主动给客户端发送消息
第三步:客户端请求静态资源,执行资源代码的过程中建立了socket的长连接,页面呈现
第四步:服务端源代码变化,向客户端发送一个消息
第五步:客户端收到消息,并重新刷新页面,执行第三步
第六步:重复第四步和第五步
我们可以先在脑海中构建上面这样一个思路,看看有没有什么问题,后续会有代码的实战。
我们很容易想到,在客户端收到消息后直接刷新页面是不是有点过于草率了?
因为刷新页面意味着所有代码重新执行,那么之填好的表单信息就都丢掉了,需要重新填写。
如果更新的粒度还可以再细一些,就会更好。例如一些css的改变,我们就不必刷新整个页面,而是直接覆盖原来的css,这样原来填好的表单信息(也就是状态)就不会丢失。这个过程完全是“趁热”的,也可以叫做“热替换”。
所以流程图需要变化一下:
可以看到其实就是多了一个判断:
当接收到来自服务端的消息之后,进行一个判断,如果源代码改变的波动比较大,那么依然进行全局的刷新,如果波动范围比较小,那么就进行局部的替换。
额外的话
其实随着前端慢工程化的到来,我们几乎不太可能直接在宿主环境运行的代码上更改了,一般来说我们写的源代码,例如react代码,都是在修改时候,有一个编译的过程,然后再变成浏览器可以执行的代码。所以会多一个编译的过程,如下图所示:
以上就是我们通过自己的思考可以想到的一个方案,我们接下来就根据这个方案来手撕一个极其简单的热更新,不进行吹毛求疵,而是主要领会起核心的逻辑。
那么开始吧!
第二部分:代码实战
一、基础准备
首先我们肯定是需要一个npm包来模拟工程化的场景,我们会模拟一个如下的过程:
源代码修改 -> 编译 -> 静态资源 -> socket通知 -> 全局刷新
所以我要介绍以下几个比较实用的工具:
express:一个可以快速搭建静态服务器的库 -- 用于托管静态资源,客户端好访问
rollup:一个资源打包工具 -- 用于模拟编译过程
chokidar:一个监听本地文件变化的库 -- 用于感知源代码的变化
fs-extra:一个方便操作文件的库 -- 用于读写文件
socket.io:一个提供socket服务的库 -- 用于和客户端通信
温馨提示
文章结尾部分有完整代码资源,阅读过程中不用一定一步一步跟着操作。
第一步:我们先搭建环境
// 随便创建一个文件夹:hot-demo
mkdir hot-demo
npm init -y
cd hot-demo
npm i express rollup chokidar fs-extra -S
第二步:开始写源代码
mkdir src
cd src
touch index.js
touch util.js
js文件写入以下内容
// util.js
export const add = (a, b) => {
return a + "--" + b;
};
//index.js
import { add } from "./util";
function init() {
const element = document.createElement("div");
element.innerHTML = add("hello", "story");
document.body.appendChild(element);
}
init();
以上的代码我们就可以当作程序员直接写的代码,来模拟我们写的vue或者react源代码,但是实际上它是以ESModule模块化的方式去写的,因此我们将package.json中的type字段变为改为“module”。
{
...
"type": "module",
"scripts": {
"serve": "node serve.cjs"
},
...
}
并且我们需要将其打包,目的是来模拟我们的编译过程。所以这个时候我们需要用到rollup。
根目录下创建serve.cjs
const { rollup } = require("rollup");
async function build() {
const bundle = await rollup({
input: "./src/index.js",
});
const outputOptions = {
format: "umd",
file: "./dist/bundle.js",
};
await bundle.generate(outputOptions);
await bundle.write(outputOptions);
}
async function start() {
await build();
}
start();
当我们运行npm run serve的时候,就可以将两个文件打包成为一个文件了,并且生成了一个dist目录。
为了使得打包后的文件生效,我们直接在dist文件夹下新建一个index.html,然后引入该文件,然后在浏览器打开就可以了。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<script src="./bundle.js"></script>
</body>
</html>
但这样做也太弱智了,我们需要将这个dist目录托管到一个服务器中,供外部访问。因此express就派上用场了。
// serve.js
const { rollup } = require("rollup");
+ const express = require("express");
+ const app = express();
+function listen() {
+ app.use(express.static("dist"));
+ app.listen(3000, () => console.log("3000 is listen ..."));
+}
async function build() {
const bundle = await rollup({
input: "./src/index.js",
});
const outputOptions = {
format: "umd",
file: "./dist/bundle.js",
};
await bundle.generate(outputOptions);
await bundle.write(outputOptions);
}
async function start() {
await build();
+ listen()
}
start();
接下来npm run serve的结果就是既能够编译,也提供了一个静态服务,我们只需要访问,localhost:3000/index.html,就可以访问到该页面。
接下来需要做的就是监听一个文件的变化,然后来自动编译而不是手动的npm run serve的编译。
我们需要用到chokidar这个库,在serve.cjs中进行以下新增以下内容:
// serve.js
const { rollup } = require("rollup");
const express = require("express");
+ const chokidar = require("chokidar");
const app = express();
function listen() {
app.use(express.static("dist"));
app.listen(3000, () => console.log("3000 is listen ..."));
}
+async function onFileChange(callback) {
+ chokidar.watch(".").on("change", (path) => {
+ console.log(path, "发生了变化");
+ if (path.includes("src")) { // 只监听src下的文件变化
+ build().then(() => { // 发生变化自动编译,则dist下就是最新的代码
+ callback(path);
+ });
+ }
+ });
+}
async function build() {
const bundle = await rollup({
input: "./src/index.js",
});
const outputOptions = {
format: "umd",
file: "./dist/bundle.js",
};
await bundle.generate(outputOptions);
await bundle.write(outputOptions);
}
async function start() {
await build();
+ onFileChange((path) => {
+ console.log(path , 'changed')
+ });
listen()
}
start();
当文件发生变化的时候,dist就会重新编译为最新的代码。这个时候,就要通知客户端了,我这边变了,你这边也得赶紧刷新了(先做一个无脑刷新)。
根据我们之前的设计,需要提供一个socket服务,我们使用sockte.io这个库。
// serve.js
const { rollup } = require("rollup");
const express = require("express");
const chokidar = require("chokidar");
+ const io = require("socket.io");
const app = express();
+ const server = http.createServer();
function listen() {
app.use(express.static("dist"));
app.listen(3000, () => console.log("3000 is listen ..."));
+ server.listen(3001, () => console.log("3001 is listen ..."));
}
async function onFileChange(callback) {
chokidar.watch(".").on("change", (path) => {
console.log(path, "发生了变化");
if (path.includes("src")) { // 只监听src下的文件变化
build().then(() => { // 发生变化自动编译,则dist下就是最新的代码
callback(path);
});
}
});
}
+function createSocket() {
+ const socketInstance = io(server, { cors: true });
+ socketInstance.on("connection", function (socket) {
+ console.log("连接成功");
+ socket.emit("msg", "你好");
+ socket.on("msg", function (data) {
+ console.log("服务器端接受到数据:%j", data);
+ });
+ });
+ return socketInstance;
+}
async function build() {
const bundle = await rollup({
input: "./src/index.js",
});
const outputOptions = {
format: "umd",
file: "./dist/bundle.js",
};
await bundle.generate(outputOptions);
await bundle.write(outputOptions);
}
async function start() {
await build();
const socket = createSocket();
onFileChange((path) => {
console.log(path , 'changed')
});
listen()
}
start();
上面只是提供了服务端的服务,还需要客户端来连接才可以,所以在src下新建socket.js,并且html中引入socket.min.js(来自socket.io/client-dist/socket.io.min.js)这是它的用法。
<html lang="en">
...
<head>
+ <script src="./socket.js"></script>
</head>
...
</html>
//socket.js
export function createSocket() {
const socket = io.connect("ws://127.0.0.1:3001/");
socket.on("msg", function (data) {
console.log(data);
});
socket.on("hotUpdate", function (data) {
const { type, hash } = data;
if (type === "reload") {
self.location.reload();
} else {
...
}
});
socket.emit("msg", "你好");
}
//index.js
import { add } from "./util";
+ import { createSocket } from "./socket";
function init() {
const element = document.createElement("div");
element.innerHTML = add("hello", "hot-demo");
document.body.appendChild(element);
+ createSocket();
}
init();
现在当我们的服务开启后,就会有热更新的效果了。
但是这其实是一个非常粗暴的热更新,因为每次都是强制刷新。我们能不能做到当改变某些代码的时候,部分刷新呢?
其实可以,我们可以写一段逻辑,当监听到css文件的改变时,我们给客户端发一个消息,这个消息是一个hash值,让客户端重新请求一段逻辑,这端逻辑可以覆盖原来的css样式,这样的话,就做到不刷新页面而通过覆盖的方式了。
所以我们增加一个css.js文件
//css.js
export default function addStyle(element) {
element.style.background = "blue";
}
//index.js
import { add } from "./util";
import { createSocket } from "./socket";
+ import style from "./css";
function init() {
const element = document.createElement("div");
element.innerHTML = add("hello", "hot-demo");
+ style(document.body);
document.body.appendChild(element);
createSocket();
}
init();
当css.js的内容,发生改变时,我们试图让它发给客户端一个局部更新的消息,并且需要生成一个脚本,这个脚本是用来覆盖css样式的,用该修改样式,而不影响状态。
// serve.cjs
const { rollup } = require("rollup");
const chokidar = require("chokidar");
const express = require("express");
const http = require("http");
+ const fs = require("fs-extra");
const io = require("socket.io");
+ const path = require("path");
var server = http.createServer();
const app = express();
async function build() {
const bundle = await rollup({
input: "./src/index.js",
});
const outputOptions = {
format: "umd",
file: "./dist/bundle.js",
};
await bundle.generate(outputOptions);
await bundle.write(outputOptions);
}
function listen() {
app.use(express.static("dist"));
app.listen(3000, () => console.log("3000 is listen ..."));
server.listen(3001, () => console.log("3001 is listen ..."));
}
async function onFileChange(callback) {
chokidar.watch(".").on("change", (path) => {
console.log(path, "发生了变化");
if (path.includes("src")) {
build().then(() => {
callback(path);
});
}
});
}
function createSocket() {
const socketInstance = io(server, { cors: true });
socketInstance.on("connection", function (socket) {
console.log("连接成功");
socket.emit("msg", "你好");
socket.on("msg", function (data) {
console.log("服务器端接受到数据:%j", data);
});
});
return socketInstance;
}
+function genCssFile(hash, filePath) {
+ filePath = path.resolve(__dirname, filePath);
+ const string = fs.readFileSync(filePath);
+ const content =
+ string.toString().replace("export default", "") +
+ "\r\naddStyle(document.body)";
+ fs.outputFileSync(path.resolve(__dirname, `dist/${hash}.js`), content);
}
async function start() {
console.log("----------------第一次进行构建---------------");
await build();
console.log("----------------开始监听文件变化---------------");
const socket = createSocket();
onFileChange((path) => {
+ if (path === "src/css.js") {
// 目前只支持css.js的更新
+ const hash = (Math.random() * 1000000).toString().slice(0, 5);
+ genCssFile(hash, path);
+ socket.emit("hotUpdate", { type: "hot", hash });
+ } else {
+ socket.emit("hotUpdate", { type: "reload" });
+ }
});
console.log("----------------开启本地服务---------------");
listen();
}
start();
那么在客户端我们也不能忘了要接受消息,并做不同的行为。
+ let lasthash = "";
+ let currenthash = "";
+ const loadScript = (src) => {
+ const script = document.createElement("script");
+ script.src = src;
+ document.head.appendChild(script);
+};
export function createSocket() {
const socket = io.connect("ws://127.0.0.1:3001/");
socket.on("msg", function (data) {
console.log(data);
});
socket.on("hotUpdate", function (data) {
const { type, hash } = data;
if (type === "reload") {
self.location.reload();
} else {
+ lasthash = currenthash;
+ currenthash = hash;
+ loadScript(`http://127.0.0.1:3000/${hash}.js`);
}
});
socket.emit("msg", "你好");
}
使用script脚本请求到的js脚本就是专门用来覆盖样式的,我们在主页再加一个input标签用来做状态的存储。
import { add } from "./util";
import { createSocket } from "./socket";
import style from "./css";
function init() {
const element = document.createElement("div");
element.innerHTML = add("hello", "hot-demo");
+ const input = document.createElement("input");
style(document.body);
document.body.appendChild(element);
+ document.body.appendChild(input);
createSocket();
}
init();
效果如下:
实际上这个样式的改变是由新请求的js代码导致的。
所以并不会对原有的状态造成影响和破坏,因此input里的hello依然不会变。
好了,那么以上就是我们使用自己的思路基本实现了热更新的基本功能,并认识了热更新的基本原理。那么webpack的热更新原理真的是这个样子的么,我们还是需要从源码的角度去看!接下来就进入源码的环节!
第三部分:源码解读
我们主要看webpack-dev-server这个包就好了,因为它是主要实现热更新的包。
其中主要的实现就是分为客户端和服务端,我们先来看一下服务端的实现。
一、服务端
位置:lib/Server.js
...
class Server {
...
}
module.exports = Server
可以看到整个文件暴露了一个Server类,可以猜测是供外部调用的。他的核心方法有这么几个:
...
class Server {
...
getServerTransport(){
// 提供socket服务
return this.options.type === 'sockjs'
? require("./servers/SockJSServer")
: require("./servers/WebsocketServer");
// 根据配置项选择使用哪一种Socket服务
},
setupApp(){
//创建app用于托管静态资源
this.app = new express();
},
setupMiddlewares(){
// 根据配置情况注册和调用中间件
let middlewares = [];
middlewares.push(
// 中间件1 webpack-dev-middleware
// 中间件2 http-proxy-middleware
// 中间件3 compression
// 中间件4 getExpress().static
)
// 其中就包括处理跨域的,托管静态服务等等
middlewares.forEach((middleware) => {
if (typeof middleware === "function") {
(this.app).use(middleware);
} else if (typeof middleware.path !== "undefined") {
(this.app).use(middleware.path, middleware.middleware);
} else {
(this.app).use(middleware.middleware);
}
});
},
watchFiles(){
const chokidar = require("chokidar");
const watcher = chokidar.watch(watchPath, watchOptions);
// disabling refreshing on changing the content
if (this.options.liveReload) {
watcher.on("change", (item) => {
if (this.webSocketServer) { // 文件发生改变,给客户端发送消息
this.sendMessage(
this.webSocketServer.clients,
"static-changed",
item
);
}
});
}
this.staticWatchers.push(watcher);
},
start(){
// 统一的开启和执行
}
}
module.exports = Server
位置:lib/servers/**
在servers下有三个文件:BaseServer.js SockJSServer.js WebsocketServer.js
他们都是对外提供socket服务的,就像我们在实战当中使用socket.io对外提供服务一样只是方式不同而已。
二、客户端
位置:client-src/clients/**
这个位置有两个文件 SockJSClient.js WebSocketClient.js,他们分别对应服务端提供的两种不同的服务的连接方式。
export default class SockJSClient {
constructor(url) {
// SockJS requires `http` and `https` protocols
this.sock = new SockJS(
url.replace(/^ws:/i, "http:").replace(/^wss:/i, "https:")
);
this.sock.onerror =
(error) => {
log.error(error);
};
}
onOpen(f) {
this.sock.onopen = f;
}
onClose(f) {
this.sock.onclose = f;
}
onMessage(f) {
this.sock.onmessage =
(e) => {
f(e.data);
};
}
}
他们都对如何连接socket进行了封装,目的是用于socket通信。重点是下面这个socket.js,包含如何对消息进行处理。
位置:client-src/socket.js
const socket = function initSocket(url, handlers, reconnect){
client.onClose(() => { // 不断和服务端进行连接直至链接成功
if (retries === 0) {
handlers.close();
}
client = null;
// After 10 retries stop trying, to prevent logspam.
if (retries < maxRetries) {
const retryInMs = 1000 * Math.pow(2, retries) + Math.random() * 100;
retries += 1;
log.info("Trying to reconnect...");
setTimeout(() => {
socket(url, handlers, reconnect);
}, retryInMs);
}
});
client.onMessage(
/**
* @param {any} data
*/
(data) => {
const message = JSON.parse(data);
if (handlers[message.type]) { // 监听,服务端发送的消息,使用handlers对应的方式进行处理。
handlers[message.type](message.data, message.params);
}
}
);
}
export default socket
位置:client-src/index.js 这个属于核心的函数
const onSocketMessage = {
hot() {
..
},
liveReload() {
if (parsedResourceQuery["live-reload"] === "false") {
return;
}
options.liveReload = true;
},
invalid() {
...
sendMessage("Invalid");
},
hash(hash) {
status.previousHash = status.currentHash;
status.currentHash = hash;
},
ok() {
sendMessage("Ok");
if (options.overlay) {
overlay.send({ type: "DISMISS" });
}
reloadApp(options, status);
},
close() {
log.info("Disconnected!");
if (options.overlay) {
overlay.send({ type: "DISMISS" });
}
sendMessage("Close");
},
}
const socketURL = createSocketURL(parsedResourceQuery);
socket(socketURL, onSocketMessage, options.reconnect);
以上只贴了主要的几个消息类型,如果服务端发来不同的消息就执行不同的逻辑。其中最为核心的是ok方法。当服务端代码变更编译完成之后,就会发送ok的消息。客户端就会执行reloadApp方法。
在reloadApp中有这样一段逻辑。
function reloadApp({ hot, liveReload }, status) {
if (status.isUnloading) {
return;
}
const { currentHash, previousHash } = status;
const isInitial =
currentHash.indexOf(/** @type {string} */ (previousHash)) >= 0;
if (isInitial) {
return;
}
function applyReload(rootWindow, intervalId) {
clearInterval(intervalId);
log.info("App updated. Reloading...");
rootWindow.location.reload();
}
const search = self.location.search.toLowerCase();
const allowToHot = search.indexOf("webpack-dev-server-hot=false") === -1;
const allowToLiveReload =
search.indexOf("webpack-dev-server-live-reload=false") === -1;
if (hot && allowToHot) { // 局部更新
log.info("App hot update...");
hotEmitter.emit("webpackHotUpdate", status.currentHash);
if (typeof self !== "undefined" && self.window) {
// broadcast update to window
self.postMessage(`webpackHotUpdate${status.currentHash}`, "*"); // 这个是为了通知其他的同源页面也更新,因为有的时候我们会开好几个tab。
}
}
else if (liveReload && allowToLiveReload) { //刷新页面
let rootWindow = self;
const intervalId = self.setInterval(() => {
if (rootWindow.location.protocol !== "about:") {
// reload immediately if protocol is valid
applyReload(rootWindow, intervalId);
} else {
rootWindow = rootWindow.parent;
if (rootWindow.parent === rootWindow) {
// if parent equals current window we've reached the root which would continue forever, so trigger a reload anyways
applyReload(rootWindow, intervalId);
}
}
});
}
}
那么可以看到,当reloadApp的时候,我们会根据传来的消息做不同的处理,这就特别像我们在实战篇中写的那个不同的逻辑。
好了,这其实就是所有的源码的解读,如果我们真的在实战中演练的比较好,那么再来阅读这个源码就不会那么陌生了,甚至觉得它本应该这样。
三、资源
近期好文
shell、bash、zsh、powershell、gitbash、cmd这些到底都是啥?
从0到1开发一个浏览器插件(通俗易懂)
用零碎时间个人建站(200+赞)
更多精彩内容请访问我的个人网站 new-story.cn
创作不易,如果您觉得文章有任何帮助到您的地方,或者触碰到了自己的知识盲区,请帮我点赞收藏一下,或者关注我,我会产出更多高质量文章,最后感谢您的阅读,祝愿大家越来越好。