从零学脚手架(八)---webpack-dev-server源码分析

1,531 阅读12分钟

上一篇中介绍了webpack-dev-server属性配置

这一篇就简单的梳理下webpack-dev-server内部实现。

由于涉及到源码解析,所以会涉及到一些比较难啃的知识,我会尽量进行简单化描述。

但如果还是具有具有难度 或 对 webpack-dev-server内部实现不感兴趣的朋友,也可以完全跳过此篇。

调试webpack-dev-server

配置调试方式

日常开发开发中,如果对代码逻辑不熟悉,最简单的方法就是调试,一步步观察流程。

学习webpack-dev-server源码,最简单的方法也就是就行调试,不过调试webpack-dev-server源码毕竟不像调试本身项目代码那样简单,必定需要做一些配置,

下面先简单介绍两种源码webpack-dev-server方式

浏览器调试

现在都知道webpack是执行于Node.js环境下,所以调试webpack也就是调试Node.js程序。

伟大的Chrome浏览器就给我们提供了调试Node.js程序的方案。

首先在package.json文件scripts属性中添加了一个debug指令,使用这个命令启动调试Node.js程序

inspect-brk属性就是设置调试Node.js程序参数 :

5858代表启动Node.js程序的端口号

./node_modules/webpack-dev-server/bin/webpack-dev-server.js 文件代表调试的指定文件,

接下来就该设置浏览器

在Chrome浏览器地址栏输入chrome://inspect会进入一个设置页面

:whale2: 因为我使用的是新版Edge ,所以显示的为edge://inspect

点击Open dedicated DevTools for Node 便可以进入调试面板

在调试面板中设置需要调试Node.js端口号

此时启动 yarn debug 便会被Chrome捕捉,就可以进行调试

IDE调试代码

常用IDE工具都可以调试Node.js程序。

可以使用IDE工具配置调试webpack-dev-server。

我使用的是WebStorm。可以做到零配置调试

执行点击Debug start就可以执行当前命令的调试

至于VS Code,没用过,不太了解配置信息。

webpack-dev-server启动流程

下面简单介绍下webpack-dev-server启动流程。

在此只介绍下关键流程代码。具体每一步细节有兴趣的朋友可以自行调试查看

webpack-dev-server执行的第一个文件是 webpack库的 /bin/webpack.js 文件模块 ,也就是入口文件

原因上一篇已经介绍过:webpack-cli@4.0开始,webpack-dev-server执行命令改为了 webpack serve。使用了webpack命令

webpack库的 /bin/webpack.js 文件模块 中调用了 webpack-cli库的 /bin/cli.js 文件模块

:whale2: 执行webpack-cli时检测webpack-cli是否安装就是在webpack库的 /bin/webpack.js 文件模块执行的,有兴趣的朋友可以自行查看。

之后在 webpack-cli的 /bin/cli.js 文件模块 中调用了 lib/bootstrap.js 启动文件模块

然后在 lib/bootstrap.js 启动文件模块中创建了 /lib/webpack-cli.js 模块实例 (WebpackCLI) 并调用 run()

之后WebpackCLI.run() 中根据命令调用对应@webpack-cli库中对应文件。

webpack serve,根据serve命令参数调用 @webpack-cli库 /serve/ 中模块 。

:whale2::whale2: @webpack-cli是webpack-cli@4.X版本依赖

在 @webpack-cli 中 /serve/index.js 文件模块中调用 /serve/startDevServer.js 文件模块

最后在 /serve/startDevServer.js 创建 webpack-dev-server 中 /lib/Server.js 模块实例对象。

并且调用 Server.listen() 监听启动服务器。

以上,就是webpack-dev-server服务器的启动流程。

webpack-dev-server服务器启动时跨了好几个库调用代码,

webpack ---> webpack-cli ---> @webpack-cli ---> webpack-dev-server。不过只要弄清楚了执行逻辑,理解起来并不会有多大困难

源码模块解析

express服务器

在上一篇说到过webpack-dev-server内部使用了express框架来作为服务器。

先来看一下express、

webpack-dev-server的服务器是 /lib/Server.js 文件模块。

关于express也就是在此模块中。

/lib/Server.js 模块构造函数中,初始化了许多东西,其中就有express

/lib/Server.js 构造函数中便初始化了express

可以看到在 Server.setupApp() 中构建了express服务器实例

然后在 Server.createServer() 中赋值给了 Server.listeningApp

最后在 Server.listen() 进行了启动。

Server.listen() 就是在 @webpack-cli 中 /serve/startDevServer.js 文件模块调用的。

:whale2: Server.setupApp()Server.createServer() 之间为 express 添加了配置的中间件。

监听代码文件更新

webpack-dev-server中,最主要的功能就是:监听代码变化触发重新编译编译完成后通知浏览器更新数据

其中监听代码文件变化触发重新编译是由webpack自身提供的一个功能。

webpack配置项中具有一个watch属性,此属性就代表是否要处于监听状态,监听代码文件变化

先来做一个简单的测试。

新建一个webpack.watch.js配置文件,在此配置文件设置watch属性

const { merge } = require('webpack-merge');
const common = require('./webpack.common');
//  使用node。js的导出,将配置进行导出
module.exports = merge([
  common(true),
  {
    mode:'development',
    //  监听文件变化
    watch: true,
    //  监听选项
    watchOptions: {
      //  忽略某些目录  可以为正则或者数组
      ignored: /node_modules/,
      //  启动轮询, 默认为false  设置true或毫秒数来启动轮询
      poll: false
    }
  }
])

package.json中添加watch指令,命令执行webapck命令。

此时执行yarn watch命令打包完成后也会一直处于wating状态,等待代码文件变化

更新代码,便会触发重新打包,

watch属性仅仅提供了watching状态等待代码文件变化,进行重新编译。

:whale2: watch属性只会监听已引用的代码模块文件,对于没有引用的代码模块文件是不会监听,有兴趣的朋友可以自行测试。

打包文件内存化

使用webpack-dev-server时,会发现并没有生成本地打包文件。

在上一篇中也介绍过webpack-dev-server默认将打包文件打在了内存流,提升访问速度

webpack-dev-server默认使用了webpack-dev-middleware中间件将打包文件内存化

webpack-dev-middleware中间件也是在 /lib/Server.js构造函数中添加的

webpack-dev-middleware的 /dist/index.js 模块中调用了 /dist/utils/setupOutputFileSystem.js 模块,此模块就是设置输出流函数

可以看到,默认情况下,webpack-dev-middleware使用了memfs作为输出流,memfs就是一个内存流库

:whale2:是一个Node.js 内存流的库,具体参考官方

:whale2:webpack-dev-middleware 项目主文件是 /dist/cjs.js ,在 /dist/cjs.js 调用了 /dist/index.js

WebSocket

webpack-dev-server核心功能 编译完成后通知浏览器更新数据 就就是使用WebSocket完成的。

在服务器启动后,实例化WebSocket Server。浏览器访问时对浏览器(WebSocket Client)进行长链接。

当代码编译完成后,使用WebSocket Server向浏览器(WebSocket Client)推送消息

浏览器(WebSocket Client)接收到后完成对应操作。

WebSocket Server
Server 创建

WebSocket Server的实例化是在 Server.listen() 中进行的。

在成功启动服务器后,判断是否设置了 hotliveReload 。如果设置就调用 Server.createSocketServer() 实例化 WebSocket Server

Server.createSocketServer() 执行时,首先会根据配置选择 WebSocket Server 类型。

然后实例化此WebSocket Server。

webpack-dev-server@4.X默认使用WebsocketServer.js类型,也就是ws

webpack-dev-server@3.X默认使用SockJSServer.js类型,也就是sockjs

:whale2::whale2:

目前在webpack-dev-server@3.11.2版本使用ws会有问题,而webpack-dev-server@4.0.0beta.0版本使用sockjs也有问题,

所以WebSocket Server直接使用默认即可

消息推送

Server.js模块中具有一个sockWrite(),这个函数就是调用WebSocket Server来推送消息。

webpack-dev-server在三个时机中使用了WebSocket Server推送消息

客户端连接

在客户端连接(浏览器访问)

WebSocket Server会推送初始化信息。例如是否开启 热更新 、overlay配置信息、是否要输出打包编译进度、输入日志级别等。

代码编译完毕

在每次代码打包编译完毕后,WebSocket Server会将此次编译信息推送给客户端(浏览器)

webpack内部暴露了 编译结束后(编译成功、编译失败)的一系列钩子事件。

webpack-dev-server就是使用了此钩子函数。

Server.js构造函数中监听webpack钩子函数

编译完成后使用WebSocket Server推送消息

静态文件发生变化

webpack-dev-server提供了当静态文件变化时,使用WebSocket Server推送消息通知客户端(浏览器)自动刷新浏览器。

WebSocket Client

WebSocket client是WebSocket的客户端(浏览器)WebSocket client接收到消息时,会根据消息类型执行对应的操作,例如刷新浏览器、获取最新代码数据、输出日志等信息。

WebSocket client 代码存放在 webpack-dev-server 的 /client 目录下。WebSocket client 也是具有 SockJSClientWebsocketClient 两种类型,与WebSocket Server对应

消息处理

WebSocket client 消息处理逻辑编写在 /client/default/index.js 模块文件中。

/client/default/index.js 模块文件中有一个 onSocketMessage 对象,这个对象就是消息处理对象。

onSocketMessage 对象复制于此。添加部分注释,以便理解, 具体就不再赘述。

其实代码还是很好理解的。

有兴趣的朋友可以看看源码。

var onSocketMessage = {
  hot: function hot() {
    //	如果推送hot,就代表使用HMR。在客户端连接后推送
    options.hot = true;
    log.info('Hot Module Replacement enabled.');
  },
    
  liveReload: function liveReload() {
     //	如果推送liveReload,就代表使用liveReload。在客户端连接后推送
    options.liveReload = true;
    log.info('Live Reloading enabled.');
  },
    
  invalid: function invalid() {
     //	监听webpack编译无效时推送。invalid与done一样,是webpack钩子函数
    log.info('App updated. Recompiling...'); // fixes #1042. overlay doesn't clear if errors are fixed but warnings remain.

    //	如果设置了错误页面,就将页面进行清除
    if (options.useWarningOverlay || options.useErrorOverlay) {
      overlay.clear();
    }

    sendMessage('Invalid');
  },
    
  hash: function hash(_hash) {
    //	当前代码模块的hash值。
    status.currentHash = _hash;
  },
    
  'still-ok': function stillOk() {
    log.info('Nothing changed.');

    if (options.useWarningOverlay || options.useErrorOverlay) {
      overlay.clear();
    }

    sendMessage('StillOk');
  },
    
  logging: function logging(level) {
    //	客户端显示日志级别,客户端连接后推送
    // this is needed because the HMR logger operate separately from
    // dev server logger
    var hotCtx = require.context('webpack/hot', false, /^\.\/log$/);

    if (hotCtx.keys().indexOf('./log') !== -1) {
      hotCtx('./log').setLogLevel(level);
    }

    setLogLevel(level);
  },
  overlay: function overlay(value) {
    //	设置编译错误时,是否显示错误页面。客户端连接后推送
    if (typeof document !== 'undefined') {
      if (typeof value === 'boolean') {
        options.useWarningOverlay = false;
        options.useErrorOverlay = value;
      } else if (value) {
        options.useWarningOverlay = value.warnings;
        options.useErrorOverlay = value.errors;
      }
    }
  },
    
  progress: function progress(_progress) {
    //	是否显示当前打包进度,客户端连接后推送
    if (typeof document !== 'undefined') {
      options.useProgress = _progress;
    }
  },
    
  'progress-update': function progressUpdate(data) {
    //	当前打包进度
    if (options.useProgress) {
      log.info("".concat(data.percent, "% - ").concat(data.msg, "."));
    }

    sendMessage('Progress', data);
  },
    
  ok: function ok() {
    //	编译成功推送当前类型,
    sendMessage('Ok');

     //	如果设置了错误页面,就将页面进行清除
    if (options.useWarningOverlay || options.useErrorOverlay) {
      overlay.clear();
    }

    if (options.initial) {
      return options.initial = false;
    }

    //使用此方法重新加载数据,此方法会处理HMR
    reloadApp(options, status);
  },
    
  'content-changed': function contentChanged() {
    //	静态文件改变时推送,重新刷新页面
    log.info('Content base changed. Reloading...');
    self.location.reload();
  },
    
  warnings: function warnings(_warnings) {
    //	编译出现警告后推送 
    log.warn('Warnings while compiling.');

    var strippedWarnings = _warnings.map(function (warning) {
      return stripAnsi(warning);
    });

    sendMessage('Warnings', strippedWarnings);

    for (var i = 0; i < strippedWarnings.length; i++) {
      log.warn(strippedWarnings[i]);
    }

    //	如果显示错误页面设置警告提示,那么就显示
    if (options.useWarningOverlay) {
      overlay.showMessage(_warnings);
    }

    if (options.initial) {
      return options.initial = false;
    }
      
	//使用此方法重新加载数据,此方法会处理HMR
    reloadApp(options, status);
  },
    
  errors: function errors(_errors) {
    //	编译错误后推送
    log.error('Errors while compiling. Reload prevented.');

    var strippedErrors = _errors.map(function (error) {
      return stripAnsi(error);
    });

    sendMessage('Errors', strippedErrors);

    for (var i = 0; i < strippedErrors.length; i++) {
      log.error(strippedErrors[i]);
    }

    //	如果显示错误页面设置了错误提示,那么就显示
    if (options.useErrorOverlay) {
      overlay.showMessage(_errors);
    }

    options.initial = false;
  },
    
  error: function error(_error) {
    log.error(_error);
  },
    
  close: function close() {
    // WebSocket Server关闭前的推送
    log.error('Disconnected!');
    sendMessage('Close');
  }
};
reloadApp

reloadApp() 代码位于 /client/default/utils/reloadApp.js 文件中。

此函数是个人感觉是一个关键函数,所以在此简单介绍下。

reloadApp() 是当代码文件发生变化后的处理逻辑。

reloadApp() 根据 hotliveReload 两个属性类型做了不同处理方案。

hot 时,加载 webpack/hot/emitter 模块,用于触发热更新。

webpack/hot/emitter 模块是 webpack 中热更新定义的事件。

而当liveReload时,就直接刷新页面重新获取数据

也就是这两个hotliveReload两个属性控制了能否实时更新代码

只不过hot采取热更新方式,而liveReload是直接刷新页面

并且hot优先级高于liveReload

HMR

提到webpack-dev-server,总是离不开热更新(HMR)的话题。热更新(HMR)是webpack-dev-server提供的比较重要的功能

开发时,往往只更新一小段代码,就需要在浏览器中查看效果。

而如果仅仅更新一小段代码,让浏览器刷新重新加载所有数据。那样会极大的浪费时间。

最好的结果就是打包编译时记录当前更新的模块文件,让浏览器只更新此模块文件。

这个技术就是叫作热更新(Hot Module Replacement)

webpack-dev-server中,热更新(HMR)其实是由webpack内置的一个plugin提供的:HotModuleReplacementPlugin

使用webpack-dev-server时,如果设置了hot属性,webpack-dev-server会自动添加这个plugin

:whale2:此代码在webpack-dev-server的 /lib/utils/DevServerPlugin.js

HotModuleReplacementPlugin每次编译后会生成一个 hash,对应其编译文件。WebSocket推送类型中具有一个 hash 类型,就是推送的 hash

WebSocket client处理消息时,会存储 hash

然后在 reloadApp() 中根据当前 hash 执行 webpackHotUpdate 事件拉去最新代码。

hash: function hash(_hash) {
    status.currentHash = _hash;
},
 if (hot) {
    log.info('App hot update...');

    var hotEmitter = require('webpack/hot/emitter');

    hotEmitter.emit('webpackHotUpdate', currentHash);

    if (typeof self !== 'undefined' && self.window) {
      // broadcast update to window
      self.postMessage("webpackHotUpdate".concat(currentHash), '*');
    }
  } // allow refreshing the page only if liveReload isn't disabled
  else if (liveReload) {

webpackHotUpdate事件是webpack中定义的一个事件,代码在 /hot/dev-server.js 。其功能是获取此次编译的代码。

if (module.hot) {
	var lastHash;
	var upToDate = function upToDate() {
		return lastHash.indexOf(__webpack_hash__) >= 0;
	};
	var log = require("./log");
	var check = function check() {
		module.hot
			.check(true)
			.then(function (updatedModules) {
				//	HMR检查成功
				if (!updatedModules) {
                    //	如果没有更新的modules,则刷新页面,
					window.location.reload();
					return;
				}
				if (!upToDate()) {
					check();
				}

           		//	调用HMR处理方法,只加载更新代码
				require("./log-apply-result")(updatedModules, updatedModules);
				if (upToDate()) {
					log("info", "[HMR] App is up to date.");
				}
			})
			.catch(function (err) {
           		//	HMR检查失败,重新刷新页面
				var status = module.hot.status();
				if (["abort", "fail"].indexOf(status) >= 0) {
					window.location.reload();
				} else {
					log("warning", "[HMR] Update failed: " + log.formatError(err));
				}
			});
	};
	var hotEmitter = require("./emitter");
    //	事件定义
	hotEmitter.on("webpackHotUpdate", function (currentHash) {
		lastHash = currentHash;
		if (!upToDate() && module.hot.status() === "idle") {
			log("info", "[HMR] Checking for updates on the server...");
			check();
		}
	});
	log("info", "[HMR] Waiting for update signal from WDS...");
} else {
	throw new Error("[HMR] Hot Module Replacement is disabled.");
}

总结

:whale2::whale2::whale2:

  • 调试webpack-dev-server代码具有两种方法:浏览器和IDE
  • webpack-dev-server内部使用了express框架作为服务器
  • webpack-dev-server默认使用了内存流(memfs)存储打包文件
  • webpack-dev-server使用了webpack提供的钩子函数监听打包编译
  • wating状态是webpack提供的功能,其属性为:watch
  • 热更新(HMR)是使用了webpack内置的HotModuleReplacementPlugin,webpack-dev-server每次在代码编译完成后将hash值推送给浏览器

如果此篇对您有所帮助,在此求一个star。项目地址: OrcasTeam/my-cli

本文参考

package.json

{
  "name": "my-cli",
  "version": "1.0.0",
  "main": "index.js",
  "author": "mowenjinzhao<yanzhangshuai@126.com>",
  "license": "MIT",
  "devDependencies": {
    "@babel/core": "7.13.1",
    "@babel/plugin-transform-runtime": "7.13.7",
    "@babel/preset-env": "7.13.5",
    "@babel/preset-react": "7.12.13",
    "@babel/runtime-corejs3": "7.13.7",
    "babel-loader": "8.2.2",
    "clean-webpack-plugin": "3.0.0",
    "css-loader": "5.0.2",
    "file-loader": "6.2.0",
    "html-webpack-plugin": "5.2.0",
    "mini-css-extract-plugin": "1.3.8",
    "style-loader": "2.0.0",
    "webpack": "5.24.0",
    "webpack-cli": "4.5.0",
    "webpack-dev-server": "4.0.0-beta.0",
    "webpack-merge": "5.7.3"
  },
  "dependencies": {
    "react": "17.0.1",
    "react-dom": "17.0.1"
  },
  "scripts": {
    "start:dev": "webpack-dev-server  --config build/webpack.dev.js",
    "start": "webpack serve  --config build/webpack.dev.js",
    "build": "webpack  --config build/webpack.pro.js",
    "debug": "node --inspect-brk=5858 ./node_modules/webpack-dev-server/bin/webpack-dev-server.js",
    "watch": "webpack  --config build/webpack.watch.js"
  },

  "browserslist": {
    "development": [
      "chrome > 75"
    ],
    "production": [
      "ie 9"
    ]
  }
}

webpack.watch.js

const { merge } = require('webpack-merge');
const common = require('./webpack.common');
//  使用node。js的导出,将配置进行导出
module.exports = merge([
  common(true),
  {
    mode:'development',
    //  监听文件变化
    watch: true,
    //  监听选项
    watchOptions: {
      //  忽略某些目录  可以为正则或者数组
      ignored: /node_modules/,
      //  启动轮询, 默认为false  设置true或毫秒数来启动轮询
      poll: false
    }
  }
])