世界上最快乐的事,莫过于为理想而奋斗。
——苏格拉底
在原理篇介绍HMR实现的文章中,我们对 Webpack-dev-server 和 HMR 做了深入的剖析。在实际开发中,我们仅仅使用 Webpack-dev-server 功能可能不够,例如:
- 不能监控 Webpack 配置文件更改之后重新启动;
- 对于本身就是 Node.js 做后端服务器的项目来说,webpack-dev-server 反而会因为不能跟原服务器结合显得很鸡肋;
- 只对 Node.js 有支持,如果后端程序是 PHP、Java 写的那么 webpack-dev-server 就束手无策。
webpack-dev-server 本质上是一个 Expressjs 的服务器,而真正跟 Webpack 交互的是它用到的中间件——webpack-dev-middleware,如果我们前端项目本身就是一个 Express 服务器,那么我们可以使用 webpack-dev-middleware 和 webpack-hot-middleware 实现 webpack-dev-server 的功能,webpack-hot-middleware 这个 Express 中间件可以为 Express 服务器提供 LiveReload 功能。
本文将从头带大家零基础用 Express 来实现一个 Webpack-dev-server,总共代码行数带注释不过 150 行左右,却实现了 webpack-dev-server 的主要功能,并且添加 mock 功能,还能够实现 Webpack 配置文件 watch 功能。
Tips: webpack-dev-middleware 和 webpack-hot-middleware 的功能,还有 Koa 版本和 Hapi 版本,Koa 和 Hapi 都是 Node.js 服务器框架。
Express 核心概念
Express是一个流行的 Node.js 服务端框架,通过 Express 可以创建 Node.js 服务端程序。Express API 简单,功能却很强大,很多流行的 Web 应用开发框架都是基于 Express 来实现的,包括我们公司在内的很多公司 Web 产品后台服务底层也是使用 Express 框架来实现的,所以 Express 是可以用于线上生产环境的。
Tips: 为了后面内容实际操作,可以先创建一个
dev-server
的文件夹,并且执行npm init -y
准备好 NPM 环境,然后安装 Express 包:npm i -S express
。后续的代码都是在这个文件夹下编写对应的代码。
Router
路由(Router)是一种将 URL 和 HTTP 方法映射到特定处理回调函数的技术,例如我们希望访问/hello
这个 URL 地址,就显示hi, world
文字,那么我们在 Express 中可以这样来实现:
1 | const http = require('http'); |
在上面的代码中,app.get('url', callback)
形式就是一个GET
路由,将callback
和URL
进行了映射绑定。
在 Express 中封装了多种 HTTP 请求方式,我们主要用到的是 GET 和 POST 两种,即app.get()
和app.post()
。它们的第一个参数都是一个请求路径,第二个参数则为处理请求的回调函数。回调函数有两个参数,分别是request
和response
,即对应 HTTP 协议中的请求和响应两个概念。
Tips: Express 的路由除了纯字符串这类,还支持正则、字符串通配符、命名参数等,但是应该注意路由的解析速度,如果一个正则路由写的匹配极低,那么会影响整个 Server 应用速度的。
Request 和 Response
Request 和 Response 分别对应着 HTTP 协议中的请求和响应。在 Express 中,将跟 HTTP 请求相关的变量都放到了 Request 对象中,例如:我们可以从 Request 对象中读取用户的浏览器 UserAgent、用户的 IP 地址、用户携带的 Cookie 信息等。Response 对象则主要提供跟做出 HTTP 响应相关的函数和属性,比如设置响应内容、设置 HTTP 响应头中的设置 Cookie、设置 HTTP 状态码等。
下面的一段代码,用户访问http://localhost:3000/
则显示用户的浏览器 UserAgent:
1 | const http = require('http'); |
中间件
中间件是 Express 中最大的特性之一。我们可以将中间件看成由一串回调函数组成的回调栈,每个回调函数都会接受request
、response
和next
参数,我们可以在各个回调函数中做不同的事情,例如专门记录请求日志的回调函数、处理 Cookie 的回调函数、专门处理 HTTP 请求头的回调函数、专门做错误页面展现的回调函数…通过一个 HTTP 请求(request)经过回调栈最终对 HTTP 做出响应(response)。
在 Express 中,可以使用Express.use
方法给一个 server 添加中间件:
1 | const http = require('http'); |
Tips: 在编写 Node.js 的 Server 端应用一定要做好内存管理。在浏览器内,每个用户访问同一个页面时都是一个独立的浏览器 Tab 甚至是独立的设备,所以内存使用不当造成内存泄漏的问题也不会特别严重,而在 Node.js Server 应用中,成千上万的用户会同时访问我们的服务,这时候应该注意:1. 不同的用户数据不能使用全局变量管理;2. 小的内存泄漏问题,因为请求多了执行次数增多而造成大的内存泄漏问题,所以开发 Node.js Server 应用一定要转变思想,不能依旧停留在浏览器开发模型中。在 Express 中,用户差异数据可以使用用户自己的
request
对象来存储,参考上面中间件示例中的request.id
。
使用 Express 及其中间件来实现 Webpack-dev-server
通过之前的功能,我们了解到 Webpack-dev-server 主要功能有以下几点:
- 静态服务器;
- 代理服务器,使用 http-proxy-middleware 实现;
- 实现 Mock Server;
- 使用 webpack-dev-middleware 实现跟 Webpack 交互;
- 使用 SockJS 实现跟
webpack/hot/server
通信,实现 HMR 功能。
为了对标 Webpack-dev-server 的配置项,我们先设置一个 Webpack-dev-server 的配置文件,内容如下:
1 | // devServer.config.js |
下面的 Express 来实现 dev-server 就是使用这个配置文件来做配置。
使用 Express 实现一个静态服务器
在 webpack-dev-server 中可以设置contentBase
来做静态资源服务器。我们先用 Express 来实现一个静态资源服务器。在 Express 中要实现一个静态资源服务器可以使用 Express 内置的serve-static这个中间件来实现:
1 | // static-server.js |
这时候我们在当前路径下创建个public
文件夹,并且在里面添加个hello.js
文件,然后执行node static-server.js
,打开浏览器访问http://localhost:3000/hello.js,就会看到 hello.js 的内容了!
就这么简单,6 行代码实现了一个静态资源服务器!
添加代理服务器中间件
现在我们已经知道 Webpack-dev-server 中使用的devServer.proxy
实际是使用 Express 的中间件http-proxy-middleware来实现的了,那么我们首先在package.json
中添加这个依赖:
1 | npm install -S http-proxy-middleware |
然后按照 http-proxy-middleware 的文档,给我们刚才的代码添加 http-proxy-middleware 中间件:
1 | Object.keys(proxy).forEach(router => { |
实现 mock server 功能
对于前后端开发的项目,我们在绝大多数情况前后端开发人员会先约定接口格式,Web 前端可以按照约定接口格式使用本地的 Mock 数据进行开发,等后端接口准备就绪之后再进行使用后端接口进行联调。webpack-dev-server 提供了 proxy 配置。我们可以在开发中将接口代理到本地服务。一般我们的接口都是 JSON 格式的接口,但是 webpack-dev-server 使用的 http-proxy-middleware,已经不支持本地 JSON 文件的代理,这个 issue说明了原因。我们在开发中的 Mock 需要自己使用中间件来实现。
在这里我们使用mocker-api,我们先添加mocker-api
:
1 | npm install -S mocker-api |
然后按照 mocker-api 的文档添加中间件:
1 | // 2. mocker-api |
整合 Webpack
经过上面三步,我们的 Express 服务已经可以访问静态文件、做代理服务器、可以使用本地数据模拟 API 接口,下面要做的就是整合 Webpack 进我们的 Express 服务器,并且使用 webpack-dev-middleware 和 webpack-hot-middleware 来实现 HMR。
webpack-hot-middleware 通信机制
webpack-hot-middleware 的通信机制用的是EventSource,EventsSource 的官方名称应该是 Server-sent events(缩写 SSE)服务端派发事件,EventSource 基于 HTTP 协议,它通过 HTTP 连接到一个服务器,以 text/event-stream 格式接收事件, 只是简单的单向通信,实现了服务端推送消息到客户端,而不能实现客户端发送数据到服务端。 虽然 EventSource 不能实现双向通信,但是在功能设计上它也有一些优点,比如:
- 可以自动重连;
- 基于 HTTP 协议,可以引入polyfill支持低版本浏览器;
- 相对于 WebSocket 需要基于 TCP 设计一种新的通信协议,EventSource 更加轻量级一些。
在日常应用中,EventSource 因为受单向通信的限制只能用来实现像股票报价、新闻推送、实时天气这些只需要服务器发送消息给客户端场景中。
使用示例:
首先支持 EventSource 的浏览器,存在window.EventSource
,我们可以直接使用 EventSource 的 server 路径来实例化一个 EventSource 对象,然后可以绑定open
、message
、error
等消息,还可以通过addEventListener
方式监听具名消息:
1 | // client.js |
在服务端,需要设置一个 EventSource 的路由,然后修改 HTTP 请求头的'Content-Type': 'text/event-stream'
,最后按照 EventSource 规范发送 Response 响应内容即可:
1 | // server.js |
EventSource 事件流格式为普通的 UTF-8 字符串即可,每条消息后面跟着一个\n
来做分隔符,然后按照下面规范来规定字符串内容:
- 注释行:以
:
开头的内容是注释行,会被忽略,定时发送一条注释行可以用来防止连接超时; - 字段:字段由字段名和字段值按照
字段名: 字段值
的规范组成,规范中的字段有:event
、data
、id
和retry
。
举例来说明:
下面是未命名事件:
1 | : this is a test stream |
命名事件:
1 | event: ping |
也可以在一个事件流中同时使用命名事件和未命名事件:
1 | event: userconnect |
给 contenBase 增加 watch 功能
先来看下 webpack-dev-server 是怎么实现的。首先在 Server 端使用chokidar添加 contentBase 路径的监听,发生change
事件则触发sockWrite
发送一个content-changed
事件,这部分代码在webpack-dev-server/lib/Server.js
中:
1 | // webpack-dev-server/lib/Server.js |
webpack-dev-server 的 client 端,通过sock = new SockJS(url)
之后,添加sock.onmessage
监听,监听 Server 端消息,最终接收到的消息处理扔给了webpack-dev-server/client-src/default/index.js
的onSocketMsg
对象针对不同的消息进行处理。下面是content-changed
消息的处理,在里面我们看到了执行的是self.location.reload()
最终导致页面重载:
1 | 'content-changed': function contentChanged() { |
所以要在 webpack-hot-middleware 中实现 contentBase 的文件变化监听,然后通知 client 端页面重载,需要我们做两件事情:
- 使用 chokidar 监听文件变化;
- 然后通过 webpack-hot-middleware 推送消息到 client 端,client 端接收到消息重新加载页面。
使用 chokidar 监听文件变化
这部分代码使用 chokidar 实现的,比较简单,直接放实现代码:
1 | if (devServerConfig.watchContentBase) { |
利用 webpack-hot-middleware 实现页面重载
在 webpack-hot-middleware 中是使用 EventSource 来通信的,同时它在客户端和 Server 端都暴漏了接口可以让我们实现 Server 端到客户端的通信。我们判断出来 contentBase 内容发生变化之后,应该像 webpack-dev-server 实现一样,发送个让页面重新加载(reload)的事件。
可以在webpack-hot-middleware/middleware.js
中找到 webpack-hot-middleware 实际返回的是一个middleware
的函数,而这个函数有个publish
方法,这个方法就是利用eventStream
对象发送 Server 消息给 client 的:
1 | // webpack-hot-middleware/middleware.js |
这样,我们只需将 webpack-hot-middleware 的返回赋值给一个webpackHotMiddlewareInstance
对象,后面就可以在上面的 chokidar 代码中的_watch
函数内直接使用webpackHotMiddlewareInstance.publish
发送消息了:
1 | const webpackHotMiddlewareInstance = webpackHotMiddleware(compiler, { |
上面的代码发送了一个{action: 'reload'}
的消息,那么我们接下来需要做的就是让 client 端页面处理这个消息,我们可以在webpack-hot-middleware/client.js
中找到下面的代码:
1 | // webpack-hot-middleware/client.js |
说明这个client.js
提供了subscribeAll
和subscribe
这两个跟消息订阅相关的接口函数,那么我们再顺藤摸瓜看下subscribeAllHandler
和customHandler
究竟是什么,继续在client.js
中查找,找到了processMessage
这个方法:
1 | // webpack-hot-middleware/client.js |
这个方法就是处理接收到的 Server 端消息。最终所有的消息都扔给了subscribeAllHandler
。而存在obj.action
内的消息,如果action
值不在switch
分支,最后会进入default
交给customHandler
处理。switch
这里只处理了building
、built
、sync
3 个action
,知道这些之后,我们就可以在 Webpack 的入口文件中,增加下面订阅 Server 消息的代码,然后reload
页面:
1 | import webpackHotMiddleware from 'webpack-hot-middleware/client?path=/__webpack_hmr&timeout=20000'; |
实现 Webpack 配置文件监控自动重启 dev-server
最后我们在一开始配置 Webpack 配置文件的时候,每次想看效果都要重启 dev-server,如果手动重启还是比较麻烦的,这里在介绍一个使用nodemon的方式来监控webpack.config.js
文件变化、自动重启 dev-server 的方式。
我们可以在package.json
中添加一个 NPM scripts:
1 | { |
Tips: 除了这种方式还可以通过 Node.js 的
child_process.fork
来 fork 一个进程执行dev-server.js
,同时使用 chokidar 来监控webpack.config.js
变化,一旦变化则 kill 掉进程重新 fork 新的进程执行dev-server.js
。
收尾整理
好了,到这里我们的 dev-server 就已经完成了,为了更好地被使用,我们也可以封装成一个 Node.js 模块,通过传入webpack.config.js
的devServer
配置来启动我们的 server。我们还可以return
一个 Promise 对象,方便绑定回调,同时我们还需要使用Compiler
的done
hook 来监听 Webpack 打包的进展和结果,通过拿到的stats
来展现打包结果。最后整个代码如下:
1 | // server.js |
Tips:测试的代码这里不再做讲解了,直接打开本篇文章的代码然后执行
npm start
查看我们的 dev-server 效果吧:
- HMR:修改
index.js
内容,就可以看到 HMR 效果了,比如把app.style.background = '#99d';
修改成其它颜色;- contentBase 和 watch:修改
public/hello.js
内容,保存则会直接接收到 reload 消息,重载页面;- proxy:可以访问
http://localhost:3000/users/
查看效果;- mock:可以按照
mock/index.js
的配置访问各接口试下,比如:http://localhost:3000/repos/hello
;- webpack.config.js watch:修改下配置文件,服务就重新启动了。
总结
对于后端服务已经是 Express 或者有自己的后端逻辑需要 Express 来实现的时候,例如在我们项目中,后端服务器实际为 PHP+Smarty 模板的,我们就是用自定义的 Express 服务器实现一个 dev-server。它支持 Webpack-dev-server 的全部功能(本文内容就是其中一部功能),还能够利用 PHP bin
命令来做 Smarty 模板数据 Mock 和模板渲染。本篇文章就是最简单地实现了 Webpack-dev-server 功能,并且我们还可以继续在本节源码技术上扩展自己的 Express 服务器功能。通过本文的内容,可以让我们更好地理解 Webpack-dev-server 和 HMR 内核实现,并且在实际项目中得到应用。