React-dev-utils主要用于 React 开发过程中的开发工具,例如创建开发服务器、热更新、错误处理等功能。
在开发时遇到了一个问题,在项目第一次热更新时,即使项目编译成功也会在document.body添加一个iframe覆盖
react-dev-utils: 9.0.3
观察ifame元素查找关键字,并打好debugger。查看调用栈发现问题,便开始了研究之路
实现原理
react-dev-utils注册websocket
var connection = new SockJS(
url.format({
protocol: window.location.protocol,
hostname: window.location.hostname,
port: window.location.port,
// Hardcoded in WebpackDevServer
pathname: '/sockjs-node',
})
);
- onmessage监听消息。并根据相应消息进行对应的逻辑处理
-
比如编译结果成功时会接收到'{"type":"ok"}'的消息并调用
handleSuccess函数function handleSuccess() { clearOutdatedErrors(); var isHotUpdate = !isFirstCompilation; isFirstCompilation = false; hasCompileErrors = false; // Attempt to apply hot updates or reload. if (isHotUpdate) { tryApplyUpdates(function onHotUpdateSuccess() { // Only dismiss it when we're sure it's a hot update. // Otherwise it would flicker right before the reload. tryDismissErrorOverlay(); }); } } -
调用
tryDismissErrorOverlay函数,底层是调用react-error-overlay的dismissBuildError函数
-
react-error-overlay的react-error-overlay函数export function dismissBuildError() { currentBuildError = null; update(); } -
update中的内容
function update() { // Loading iframe can be either sync or async depending on the browser. if (isLoadingIframe) { // Iframe is loading. // First render will happen soon--don't need to do anything. return; } if (isIframeReady) { // Iframe is ready. // Just update it. updateIframeContent(); return; } // We need to schedule the first render. isLoadingIframe = true; const loadingIframe = window.document.createElement('iframe'); applyStyles(loadingIframe, iframeStyle); loadingIframe.onload = function () { const iframeDocument = loadingIframe.contentDocument; if (iframeDocument != null && iframeDocument.body != null) { iframe = loadingIframe; const script = loadingIframe.contentWindow.document.createElement('script'); script.type = 'text/javascript'; script.innerHTML = iframeScript; iframeDocument.body.appendChild(script); } }; const appDocument = window.document; appDocument.body.appendChild(loadingIframe); }
小结
到这里我们可以发现iframe中的弹窗是在这里添加的,但是当第一次编译成功时,react-error-overlay中的全局变量isLoadingIframe和isIframeReady都为false。那么当第一次热更新时就会走创建iframe的逻辑,当手动删除后,而后的更新都不会增加。这是因为其添加的全局hookwindow.__REACT_ERROR_OVERLAY_GLOBAL_HOOK__提供的方法iframeReady重置了变量,不会走创建iframe的逻辑
研究拓展
出于好奇,react-dev-utils的websocket是和谁建立的,决定研究下去
全局搜索sockjs-node。搜索出来的结果还是挺多的有53个结果。仔细一想,我们启动服务时使用了webpack-dev-server启动了一恶搞服务器。客户端建立websocket需要和服务器建立连接,便先以webpack-dev-server路径下的资源这里开始查找
点击去一看,这里果然是用于创建socket
但仔细看下来后发现这里的websocket也是客户端用户接收消息的,并未查看到推送消息相关的😭
按照之前查找的结果,重新查找,最终在node_modules/webpack-dev-server/lib/Server.js路径下找到了
整理的流程图
webpack-dev-server注册websocket主要流程代码自我理解
-
使用
sockjs.createServer创建 WebSocket 服务器实例,其中sockjs_url指定了客户端 JavaScript 库的 URL,该库用于创建 WebSocket 连接。其中sockjs_url指定了用于创建 WebSocket 服务的 sockjs-client 的 URL。这个 URL 是由webpack-dev-server通过html-webpack-plugin插件生成的,会注入到 HTML 模板中const socket = sockjs.createServer({ // Use provided up-to-date sockjs-client sockjs_url: '/__webpack_dev_server__/sockjs.bundle.js', // Limit useless logs log: (severity, line) => { if (severity === 'error') { this.log.error(line); } else { this.log.debug(line); } }, }); -
监听 WebSocket 服务器的
connection事件。这里会检查连接的请求头是否符合要求,如果不符合则关闭连接socket.on('connection', (connection) => { if (!connection) { return; } if ( !this.checkHost(connection.headers) || !this.checkOrigin(connection.headers) ) { this.sockWrite([connection], 'error', 'Invalid Host/Origin header'); connection.close(); return; } this.sockets.push(connection); connection.on('close', () => { const idx = this.sockets.indexOf(connection); if (idx >= 0) { this.sockets.splice(idx, 1); } }); // ... }); -
启用配置发送消息
return this.listeningApp.listen(port, hostname, (err) => { // ... if (this.hot) { this.sockWrite([connection], 'hot'); } if (this.progress) { this.sockWrite([connection], 'progress', this.progress); } if (this.clientOverlay) { this.sockWrite([connection], 'overlay', this.clientOverlay); } if (this.clientLogLevel) { this.sockWrite([connection], 'log-level', this.clientLogLevel); } if (!this._stats) { return; } this._sendStats([connection], this._stats.toJson(STATS), true); }); // ... }); } -
如果已经有了编译结果,向客户端发送编译结果
this._sendStats([connection], this._stats.toJson(STATS), true);
其中_sendStats的作用:
_sendStats方法,该方法是其内部实现的一个方法,它的作用是将编译结果通过WebSocket发送给客户端,更新客户端的界面。
_sendStats方法接受两个参数,第一个参数是发送目标,这里传入了this.sockets,也就是所有与服务器建立了WebSocket连接的客户端;第二个参数是编译结果,这里调用了stats.toJson(STATS)方法,将webpack的编译结果转换成JSON格式接着,这个函数将编译结果保存在
this._stats属性中,方便在其他地方使用。由于webpack-dev-server使用了WebSocket实现了热更新功能,所以在每次编译完成后,需要将编译结果发送到客户端,更新客户端的界面
如果没编译好,会在addHooks处理
const addHooks = (compiler) => {
const { compile, invalid, done } = compiler.hooks;
compile.tap('webpack-dev-server', invalidPlugin);
invalid.tap('webpack-dev-server', invalidPlugin);
done.tap('webpack-dev-server', (stats) => {
this._sendStats(this.sockets, stats.toJson(STATS));
this._stats = stats;
});
};
通过调用
compile.tap方法,为 compile 和 invalid 这两个钩子函数注册一个名为 "webpack-dev-server" 的插件(plugin),并指定执行 invalidPlugin 函数作为回调函数。这里用了 tap 方法,表示添加一个新的回调函数到钩子的回调队列中。最后,调用
done.tap方法,为 done 钩子函数也注册一个名为 "webpack-dev-server" 的插件,并指定一个回调函数。当编译完成后,会调用这个回调函数,将编译生成的统计信息(stats)转换成 JSON 格式,然后通过_sendStats方法发送给当前正在监听的所有 WebSocket 连接。同时,也将这个统计信息保存在this._stats变量中,以便后续使用
-
使用
socket.installHandlers方法将 WebSocket 服务器安装到 HTTP 服务器中。大概查了一下这里的作用是: WebSocket 服务器的处理逻辑绑定到 HTTP 服务器中,从而将 HTTP 请求转发到 WebSocket 服务器中处理。socket.installHandlers(this.listeningApp, { prefix: this.sockPath, }); -
如果传入了回调函数
fn,则在 HTTP 服务器启动完成时调用该函数if (fn) { fn.call(this.listeningApp, err); }
const addHooks = (compiler) => {
const { compile, invalid, done } = compiler.hooks;
compile.tap('webpack-dev-server', invalidPlugin);
invalid.tap('webpack-dev-server', invalidPlugin);
done.tap('webpack-dev-server', (stats) => {
this._sendStats(this.sockets, stats.toJson(STATS));
this._stats = stats;
});
};
_sendStats方法,该方法是webpack-dev-server内部实现的一个方法,它的作用是将编译结果通过WebSocket发送给客户端,更新客户端的界面
_sendStats(sockets, stats, force) {
if (
!force &&
stats &&
(!stats.errors || stats.errors.length === 0) &&
stats.assets &&
stats.assets.every((asset) => !asset.emitted)
) {
return this.sockWrite(sockets, 'still-ok');
}
this.sockWrite(sockets, 'hash', stats.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');
}
}
总结
/sockjs-node 是由 webpack-dev-server 提供的,它是一个用于开发环境的静态资源服务器,可以为前端项目提供本地开发环境。在使用 react-dev-utils 的 WebpackDevServerUtils.createCompiler 方法创建编译器时,会在内部通过 webpack-dev-server 提供的 /sockjs-node 接口,与客户端通过 WebSocket 建立长连接,实现实时更新和热加载等功能。
在开发环境中,客户端文件会被打包成多个 Chunk,当其中某个 Chunk 发生变化时,webpack-dev-server 会通过 WebSocket 推送消息到客户端。具体来说,当文件发生变化时,webpack-dev-server 会发送一个 JSON 数据到客户端,该数据包含了需要重新加载的 Chunk 名称和路径等信息。客户端接收到这个 JSON 数据后,就会使用新的 Chunk 来替换旧的 Chunk,从而实现实时更新和热加载。