前端热更新原理-上篇
什么是热更新
热更新一般指app有小规模更新,以直接打补丁的形式更新app,对比之下,另一种更新就是重新下载整个app包,热更新方式常见于手游,因为现在的手游都比较大,一般都是几百兆到几个G之间,如果有小的更新就让用户重新下载完整包,估计没几天这个app就没人用了
web热更新&热加载实现的前提
如果按照上面的说法,可能web就没有热更新了,主要因为web是B/S(浏览器+服务端),浏览器主要和服务器通过http协议通信,服务端响应http请求之后,本次http连接就结束了,不像app是c/s(客户端+服务端),服务端无法推送补丁到浏览器更新,但是为什么现在web可以实现 热加载 hot-loader 或者是模块热更替 HMR 主要归功于HTML5中提出的俩种新的通信方式
-
WebSocket: 一种允许浏览器与服务器间建立tcp长链接的通信机制
-
EventSource(Server-Sent-Events): 服务器推送的一个网络事件接口。一个EventSource实例会对HTTP服务开启一个持久化的连接,以
text/event-stream
格式发送事件, 会一直保持开启直到被要求关闭。(具体的可以看这篇文章 www.ruanyifeng.com/blog/2017/0… )
区别:
-
与WebSocket不同的是,EventSource服务端推送是单向的。数据信息被单向从服务端到客户端分发. 当不需要以消息形式将数据从客户端发送到服务器时,这使EventSource成为绝佳的选择。
-
SSE 支持断开重连,WebSocket需要自己实现
-
SSE 使用 HTTP 协议,现有的服务器软件都支持。WebSocket 是一个独立协议。
-
SSE一般只用来传送文本,二进制数据需要编码后传送,WebSocket 默认支持传送二进制数据
-
SSE 支持自定义发送的消息类型。
webpack热更新原理
热加载:不保留页面状态,简单粗暴直接刷新浏览器,类似window.location.reload()
热更新: 另一种是基于 WDS (Webpack-dev-server)
的模块热替换,只需要局部刷新页面上发生变化的模块,同时可以保留当前的页面状态(webpack-dev-server自己做不到,需要配合react-hot-loader使用),比如复选框的选中状态、输入框的输入等
Webpack 构建
运行npm run start,会在控制台打印出如下信息
其中的 hash:8420886c15f084075f1d
字段,代表一次编译的标识。当修改代码保存后,webpack会重新编译代码,控制台显示如下
发现hash已经变为 94992a48d7e645922c7b
因为这代表了一次新的编译
对比发现,会出现新的文件
新的js: main.8420886c15f084075f1d.hot-update.js
新的json: 8420886c15f084075f1d.hot-update.json
观察仔细可以发现上一次的 hash:8420886c15f084075f1d
为本次生成js和json的前缀
再来看一下浏览器端,代码修改保存,webpack编译后,浏览器都会发出一下俩个请求
可以看出请求的文件即为本次编译生成的js和json文件,然后来看一下每一个文件的内容 首先看一下json文件的内容
c
字段代表,当前变化的 module
, h
字段代表当前编译的 hash
,用于作为下次请求文件的前缀
再看一下js文件的内容, xxx
即为 moduleId
, 以后再讨论 webpackHotUpdate
webpackHotUpdate("main",{
'xxx': (function(module, __webpack_exports__, __webpack_require__) {}))
})
今天分享的内容是热更新的过程中服务端都做了什么
服务端都做了什么呢
讨论前,先运行以下几步,通过(create-react-app)来创建的react项目来调试
-
npm run eject
来生成配置 -
修改
config
目录下的webpack.config.js
const shouldUseReactRefresh = env.raw.FAST_REFRESH || false;
// 改为
const shouldUseReactRefresh = false;
这个是为了关闭 Fast Refresh
, 这个是官方实现的热更新,更快更稳定,类似于 react-hot-loader
具体的可以看 blog.logrocket.com/whats-new-i…
- 修改
config
目录下的webpackDevServer.config.js
injectClient: false
// 改为
injectClient: true
关于这个的配置将在下面说
-
去掉
webpack.config.js
中的entry中的webpackDevClientEntry
-
在
src
目录下的index.js
中增加以下代码
if (module.hot) {
module.hot.accept(() => {
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
})
}
运行 npm run start
会调用 scripts
中的 start.js
start.js
const devServer = new WebpackDevServer(compiler, serverConfig);
webpack-dev-server包下的 Server.js
,
Server.js 构造函数
class Server {
constructor(compiler, options = {}, _log) {
// webpack 的 compiler
this.compiler = compiler;
this.options = options;
updateCompiler(this.compiler, this.options);
this.setupHooks();
// 初始化app
this.setupApp();
this.setupDevMiddleware();
this.setupFeatures();
// 创建server
this.createServer();
}
}
首先看一下 updateCompiler()
函数,截取了其中主要的代码
webpack-dev-server/lib/utils/updateCompiler.js
'use strict';
/* eslint-disable
no-shadow,
no-undefined
*/
const webpack = require('webpack');
const addEntries = require('./addEntries');
const getSocketClientPath = require('./getSocketClientPath');
function updateCompiler(compiler, options) {
if (options.inline !== false) {
// 找 hmr 插件
const findHMRPlugin = (config) => {
if (!config.plugins) {
return undefined;
}
return config.plugins.find(
(plugin) => plugin.constructor === webpack.HotModuleReplacementPlugin
);
};
const compilers = [];
const compilersWithoutHMR = [];
let webpackConfig;
webpackConfig = compiler.options;
compilers.push(compiler);
// 没有找到
if (!findHMRPlugin(compiler.options)) {
compilersWithoutHMR.push(compiler);
}
// 向入口文件中添加东西
addEntries(webpackConfig, options);
// 如果找到了插件,那么运行插件(即调用插件的apply方法)
if (options.hot || options.hotOnly) {
compilersWithoutHMR.forEach((compiler) => {
const plugin = findHMRPlugin(compiler.options);
if (plugin) {
plugin.apply(compiler);
}
});
}
}
}
看一下 addEntries()
方法
webpack-dev-server/lib/utils/addEntries.js
'use strict';
const webpack = require('webpack');
const createDomain = require('./createDomain');
/**
* A Entry, it can be of type string or string[] or Object<string | string[],string>
* @typedef {(string[] | string | Object<string | string[],string>)} Entry
*/
/**
* Add entries Method
* @param {?Object} config - Webpack config
* @param {?Object} options - Dev-Server options
* @param {?Object} server
* @returns {void}
*/
function addEntries(config, options, server) {
if (options.inline !== false) {
const app = server || {
address() {
return { port: options.port };
},
// clientEntry
const domain = createDomain(options, app);
const sockHost = options.sockHost ? `&sockHost=${options.sockHost}` : '';
const sockPath = options.sockPath ? `&sockPath=${options.sockPath}` : '';
const sockPort = options.sockPort ? `&sockPort=${options.sockPort}` : '';
const clientEntry = `${require.resolve(
'../../client/'
)}?${domain}${sockHost}${sockPath}${sockPort}`;
// hotEntry
if (options.hotOnly) {
hotEntry = require.resolve('webpack/hot/only-dev-server');
} else if (options.hot) {
hotEntry = require.resolve('webpack/hot/dev-server');
}
const checkInject = (option, _config, defaultValue) => {
if (typeof option === 'boolean') return option;
if (typeof option === 'function') return option(_config);
return defaultValue;
};
const additionalEntries = checkInject(
options.injectClient,
config,
webTarget
)
? [clientEntry]
: [];
if (hotEntry && checkInject(options.injectHot, config, true)) {
additionalEntries.push(hotEntry);
}
//
config.entry.unshift(additionalEntries)
// 如果没有hmr插件时,将hmr插件加入
config.plugins.push(new webpack.HotModuleReplacementPlugin());
});
}
}
module.exports = addEntries;
通过上面可知整个 updateCompiler()
函数主要做了
- 将以下俩个文件加入到入口中
xxx/node_modules/webpack-dev-server/client/index.js
文件主要负责和服务端进行通信xxx/node_modules/webpack/hot/dev-server.js
主要负责热更新的逻辑
[
'xxx/node_modules/webpack-dev-server/client/index.js?[http://localhost:3000](http://localhost:3000/)'
'xxx/node_modules/webpack/hot/dev-server.js'
'../src/index.js'
]
- 加入
HotModuleReplacementPlugin
插件,并调用执行插件(调用apply方法)
继续看一下 setupHooks
主要做了什么,截取了其中主要代码
Server.js
setupHooks () {
const addHooks = (compiler) => {
const { done } = compiler.hooks;
done.tap('webpack-dev-server', (stats) => {
this._sendStats(this.sockets, this.getStats(stats));
this._stats = stats;
});
};
addHooks(this.compiler);
}
// send stats to a socket or multiple sockets
_sendStats(sockets, stats, force) {
this.sockWrite(sockets, 'hash', stats.hash);
this.sockWrite(sockets, 'ok');
}
setupHooks
主要做了
- 监听webpack编译完成,通过
websocket
发送消息给前端
接着向下看 setupApp
Server.js
setupApp() {
// Init express server
// eslint-disable-next-line new-cap
this.app = new express();
}
没什么好说的,接着看 setupDevMiddleware
setupDevMiddleware() {
// middleware for serving webpack bundle
this.middleware = webpackDevMiddleware(
this.compiler,
Object.assign({}, this.options, { logLevel: this.log.options.level })
);
}
看 webpackDevMiddleware
, 文件位于 webpack-dev-middleware
包下的index.js
index.js
module.exports = function wdm(compiler, opts) {
const context = createContext(compiler, options);
// 监听代码变化,重新编译
context.watching = compiler.watch(options.watchOptions, (err) => {
if (err) {
context.log.error(err.stack || err);
if (err.details) {
context.log.error(err.details);
}
}
});
setFs(context, compiler);
// 暂时先不管返回的东西
};
// 省略了一些没用的代码
setFs(context, compiler) {
let fileSystem;
// store our files in memory
const isConfiguredFs = context.options.fs;
const isMemoryFs =
!isConfiguredFs &&
!compiler.compilers &&
compiler.outputFileSystem instanceof MemoryFileSystem;
if (isConfiguredFs) {
// 省略
} else if (isMemoryFs) {
fileSystem = compiler.outputFileSystem;
} else {
fileSystem = new MemoryFileSystem();
compiler.outputFileSystem = fileSystem;
}
context.fs = fileSystem;
},
从上面代码可以看出到目前为止,主要做了
-
以
watch mode
的方式启动了webpack
,一旦监测的文件变更,便会重新进行编译打包, -
将
webpack
文件的存储方式改为了内存存储,提高了文件的存储读取效率。
继续看webpack-dev-middleware 中 index.js
返回的内容
webpack-dev-middleware/index.js
function wdm (compiler, opts) {
return Object.assign(middleware(context), {
// 省略
});
}
// middleware
module.exports = function wrapper(context) {
return function middleware(req, res, next) {
let filename = getFilenameFromUrl(
context.options.publicPath,
context.compiler,
req.url
);
return new Promise((resolve) => {
// 如果内存中文件名满足hash,且是一个文件而不是目录,就会执行processRequest
handleRequest(context, filename, processRequest, req);
// eslint-disable-next-line consistent-return
function processRequest() {
try {
// 省略代码
// 读取文件内容
let content = context.fs.readFileSync(filename);
// 省略代码
if (res.send) {
res.send(content);
} else {
res.end(content);
}
resolve();
}
});
};
};
middleware
方法是一个中间件包装函数,然后返回这个中间件,中间件的作用是拦截浏览器请求的更新文件,从内存中读取内容返回,回到 setupDevMiddleware
方法
webpack-dev-server/lib/Server.js
setupDevMiddleware() {
// middleware for serving webpack bundle
this.middleware = webpackDevMiddleware(
this.compiler,
Object.assign({}, this.options, { logLevel: this.log.options.level })
);
}
由此可知, this.middleware
即为返回的中间件
setupDevMiddleware
主要做了
-
以
watch mode
的方式启动了webpack
,一旦监测的文件变更,便会重新进行编译打包, -
将
webpack
文件的存储方式改为了内存存储,提高了文件的存储读取效率。 -
初始化
this.middleware
中间件,中间件的作用是拦截浏览器请求更新文件,从内存中读取文件内容返回
接着向下看 setupFeature()
方法
webpack-dev-server/lib/Server.js
setupFeatures() {
const features = {
middleware: () => {
// include our middleware to ensure
// it is able to handle '/index.html' request after redirect
this.setupMiddleware();
}
};
features['middleware']()
}
// this.middleware 为上面的中间件,加载该中间件
setupMiddleware() {
this.app.use(this.middleware);
}
setUpFeature()
主要做了
- 加载中间件
继续向下看 createServer()
函数
this.listeningApp = http.createServer(this.app);
主要做了
- 创建服务器
回到scripts/start.js 中 scripts/start.js
devServer.listen(port, HOST, err => {
// 省略代码
openBrowser(urls.localUrlForBrowser);
});
调用Server.js 中的 listen()
方法
webpack-dev-server/lib/Server.js
listen(port, hostname, fn) {
this.hostname = hostname;
return this.listeningApp.listen(port, hostname, (err) => {
//
this.createSocketServer();
});
}
listen()
方法主要做了
- 创建websocket
用流程图表示整个以上过程
热更新完整的流程如下
图片来自( segmentfault.com/a/119000002… )
参考文档
转载自:https://juejin.cn/post/6985081488920281119