webpack 开发体验
webpack 增强开发体验
原始开发方式:
- 编写源代码
- webpack打包
- 运行应用
- 刷新浏览器
设想理想的开发环境:
- 以HTTP Server运行
- 不是以文件形式预览
- 接近生产环境的状态
- 类似ajax这类api,不支持文件访问形式
- 自动编译 + 自动刷新
- 提供Source Map支持
- 调试错误时快速定位
实现自动编译
watch监听模式:监听文件变化,自动重新运行打包任务。(类似其他构建工具的watch)
命令行使用方法:--watch
参数启动监听模式
例如:yarn webpack --watch
实现编译后自动刷新浏览器
使用browser-sync模块的--files
参数监听文件变化,触发浏览器刷新。
例如:browser-sync dist --files "**/*"
上面实现两个开发体验的方式的缺点:
- 需要打开两个终端去执行命令,操作较麻烦。
- webpack频繁将编译后的文件写入磁盘,browser-sync从磁盘中读取文件,效率上降低了。
Webpack Dev Server 实现自动编译 + 自动刷新
webpack-dev-server 是 webpack官方的开发工具。
- 它提供用于开发的 HTTP Server 服务器
- 集成了「自动编译」和「自动刷新浏览器」等功能
安装yarn add webpack-dev-server --dev
它提供一个webpack-dev-server
的命令。
webpack-dev-server为了提高开发效率,并没有将打包结果写入到磁盘当中。
它将打包结果,暂时存放在内存中。
内部的HTTP Server从内存中读取这些文件,发送给浏览器。
这样减少很多不必要的磁盘读写操作,从而大大提高构建效率。
可以添加--open
参数,使启动服务后立即从浏览器打开。
Webpack Dev Server 静态资源访问
Dev Server 默认会将构建结果输出的文件,全部作为开发服务器的资源文件(即默认只会serve打包输出的文件)
也就是说,只要是webpack输出的文件,都可以直接被访问。
但是还有一些没有参与构建的静态资源也需要serve,就需要额外的告诉Webpack Dev Server
webpack配置的devServer.contentBase
属性,可以额外的为开发服务器指定查找资源目录。
它可以接收表示目录的字符串或数组。
配置contentBase替代copy插件
由于webpack打包任务可能使用copy(copy-webpack-plugin)插件将静态资源文件拷贝到输出目录(Dev Server是将拷贝的内容存储在内存中),所以运行HTTP可以访问到这些静态资源。
但是,由于开发阶段修改代码会频繁重复的执行webpack打包任务。
如果拷贝的文件比较多或比较大,每次执行copy任务,打包的开销就比较大,并且会降低速度。
所以拷贝任务一般会配置在打包发布版本的阶段执行,而开发阶段使用配置额外资源的查找路径devServer.contentBase
的方式去访问。
Webpack Dev Server 代理 API 服务
由于Dev Server启动了一个本地的开发服务器,默认http://localhost:8080
。
当请求后端发布到线上的API时,会因为跨域而请求失败。
虽然可以通过配置CORS时,API支持跨域。
但这需要后端和服务器配合,而且并不是任何情况下API都应该支持CORS。
例如:前后端同源部署,即发布后,前后端在同一个域名、协议、端口下,就没有必要开启CORS。
所以解决 「开发阶段接口跨域问题」 的最好的办法就是在开发服务器当中配置**「代理服务」**。
也就是将接口服务,代理到本地的开发服务地址。
Webpack Dev Server 支持通过配置(devServer.proxy
)的方式,添加代理服务。
实现:将GitHub API 代理到开发服务器
目标:将API(https://api.github.com/
)代理到本地开发服务器。
github接口的Endpoint一般都是在根目录下。
例如 https://api.github.com/users
Endpoint 可以理解为 接口端点/入口
webpack通过devServer.proxy
对象配置代理服务。
对象中的每个属性,都是一个代理规则的配置。
- 属性的名称(
key
)就是需要代理的请求路径的前缀,例如'/api'
。 - 属性的值(
value
)是为这个前缀匹配的代理规则配置。target
:代理目标,即访问key
相当于访问target/key
,他会将key
添加到后面,可通过pathRewrite
实现代理路径的重写。pathRewrite
:重写代理路径。它接收一个对象,key是正则匹配的路径字符串,value是要替换的内容。- 它修改的是path路径(参考location.pathname),例如
https://api.github.com/api/users
修改的是/api/users
。
- 它修改的是path路径(参考location.pathname),例如
changeOrigin
:设置为true
。
Host 和 changeOrigin
HTTP请求头(Request Headers)中必须包含一个 「host」 头字段
「host」 请求头指明了 服务器的域名 和 以及(可选的)端口号。(也有说是 指明了主机名 和 端口号)
如果没有给定 端口号,会自动使用被请求服务的默认端口。
例如:请求https://api.github.com/api/users
时,请求头的 「host」 为api.github.com
(默认80端口)
「host」的意义:一般情况下,服务器会配置多个网站,服务器端需要根据 「host」 判断当前请求是哪个网站,从而把这个请求指派到对应的网站。
Webpack Dev Server 在客户端对代理后的地址发起请求时,请求的地址是http://localhost:8080/api/users
,所以请求头的 「host」 为localhost:8080
。
代理背后又去请求被代理的地址https://api.github.com/users
,请求的过程中同样会带一个 「host」,而代理服务默认使用用户在客户端发起请求的 「host」,即localhost:8080
。
而localhost:8080
并不是GitHub配置的网站。请求头应为实际请求地址的「host」,即api.github.com
。
配置changeOrigin
为true
,就会以实际发生代理请求的「host」(api.github.com
)作为发起请求的「host」。
这样就不用关心,最终会把它代理成了什么样。
Source Map
通过构建编译,可以将开发环境的源代码转化为能在生产环境运行的代码。
这使得 运行代码 完全不同于 源代码。
由于调试和报错都是基于运行代码。如果需要调试应用,或运行应用时报出了错误,就无法定位。
Source Map(源代码地图) 就是解决这类问题最好的办法。
它用来映射 转换后的代码(compiled) 与 源代码(source) 之间的关系。
转换后的代码,通过转换过程中生成的 Source Map 解析,就可以逆向得到源代码。
Source Map 文件
目前很多第三方的库在打包后都会生成一个.map
后缀的Source Map文件。
它是一个 json 格式的文件,主要包含以下属性:
- version:表示当前文件所使用source map标准的版本
- sources:记录转换之前源文件的名称
- 可能是多个文件合并转换成一个文件,所以它是数组形式
- names:记录源代码中使用的成员名称
- 压缩代码时,会将开发阶段编写的有意义的变量名替换为简短的字符,从而去压缩整体代码的体积
- names记录的就是原始对应的名称
- mappings:记录转换后的代码当中的字符,与转换前所对应的映射关系。
- 它是整个source map的核心属性。
- 他是一个 Base64 VLQ 编码的字符串
{
"version": 3。
"sources": ["jquery.js"],
"names": [...],
"mappings": "Base64 VLQ编码字符串"
}
Source Map 文件使用
可以在转换后的文件中通过添加注释的方式引入source map文件。例如:
// jquery.min.js
// ...转换后的代码
//# sourceMappingURL=jquery.min.map
引入后,如果在浏览器中打开开发人员工具,开发人员工具在加载到这个js文件时发现有这个注释,它就会自动去请求这个source map文件。
然后根据这个文件的内容,逆向解析对应的源代码,以便于调试。(在开发人员工具的sources面板就会多出一个解析后的源文件)
同时因为有了映射的关系,如果源代码中出现了错误,也能很容易定位到源代码中对应的位置。
source map文件主要用于调试和定位错误,所以它对生产环境没有太大的意义,所以生产环境一般不需要生成source map文件。
Source Map 总结
解决了在前端方向引入了构建编译之类的概念之后,导致前端编写的代码与运行的代码之间不一样所产生的调试的问题。
webpack 配置 Source Map
webpack支持对打包后的结果生成对应的source map文件。
可通过devtool
属性配置指定一个生成方式。
例如:devtool: 'source-map'
webpack 基于对source map不同风格的支持,提供了12种不同的模式(实现方式)。
每种方式的 效率 和 效果 各不相同。
简单表现为:效果越少的,生成速度越快。
webpack官方文档 提供了一个 devtool
不 同模式对比表。
分别从 初次构建(打包)速度「build」、监视模式重新打包速度「rebuild」、是否适合在生产环境中使用「production」 以及 所生成的 source map 的质量「quality」4个维度对比了不同方式之间的差异。
webpack期望设置devtool时,使用特定的顺序(eval (none)除外):
[inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map
webpack的配置文件,一般返回一个配置对象,也可以返回由多个配置对象组成的数组,从而实现一次构建执行多个配置任务。
以下使用这种方式查看不同devtool模式的差异。
source-map
会生成对应的source map文件,并以常规方式在打包文件最后添加sourceMappingsURL注释
eval
eval即js当中的eval函数。
eval('console.log(123)')
会讲js代码默认运行在一个虚拟机环境中,在开发者环境中执行这条语句,可以看到它的来源指向VM**
,点击可跳转到sources面板查看它的源代码,tab名即虚拟机环境名称VM**
。
可以通过 sourceURL 修改它的运行环境的 名称/所属文件路径 。
它修改的只是个标识而已,代码依然在虚拟机上运行。
执行eval('console.log(123)' //# sourceURL=./foo/bar.js)
,它的来源就会指向./foo/bar.js
。
使用 eval 模式 ,会在打包文件中将要执行的代码放到eval()方法中执行,并且在eval函数执行的字符串最后,通过sourceURL去说明所对应的模块文件路径。
eval 模式 只指明了对应模块的文件路径,并没有指定source map路径(实际上也没有生成source map)。
如此,浏览器在通过eval执行这段代码时,就知道所对应的源代码文件。查看源代码时,只能看到对应的模块打包后的代码。
- eval模式只能正确定位到代码所属的模块文件(路径)
- 这种模式不会生成 source map,也就是和 source map 没有太大关系。
- 构建速度最快:不需要生成 source.map
- 效果最差:只能定位源代码文件的路径,而不知道具体的行列信息
eval-source-map
与eval模式类似,但它查看的代码内容,是编译前的内容,所以它能定位到具体的行和列的信息。
原因是它生成了一个 Data URLs 地址的 source map。
// eval执行的字符串
// ...执行代码
//# sourceURL=[module]
//# sourceMappingURL=data:application/json;charset=utf-8;base64,[base64内容]
eval-cheap-source-map
cheap 表示会生成 廉价(阉割版) 的source-map。
效果:
- 查看的源码是经过loader转换后的代码(如果配置了对应的loader),导致定位到的行与实际源代码不一致。
- 无法定位到列。
- 表现为通过开发人员工具跳转到源代码时,光标只会定位到代码的行,不会定位到代码的列。
由于少了一些效果,所以生成速度比 eval-source-map快很多。
eval-cheap-module-source-map
与eval-cheap-source-map的区别是,查看的源码与实际源文件一样(loader转换前)。
但同样无法定位列。
devtool 总结
devtool是将几种配置拼接在一起使用,webpack期望设置devtool时,使用特定的顺序(eval (none)除外):
[inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map
拆解介绍:
-
eval
:是否使用eval执行模块代码。 -
inline
:指定source map以Data URLs方式嵌入到打包文件。 -
hidden
:指定不会在打包文件中,通过注释引入source map文件。- 一般用于开发第三方包时使用。
-
nosources
:在开发人员工具中会看到行列信息,但无法看到源码(报错:Could not load content for xxxx)。- 用于在生产环境避免其他人看到源码的同时,定位错误。
- 可能是通过未定义
sourcesContent
实现。
-
source-map
:表示会生成source map,eval/inline模式会以 Data URLs 形式嵌入到打包文件中,其他模式以物理文件(.map
)形式生成 -
cheap
:source map是否包含行信息。- 会解析生成阉割版的source map,即经过loader加工后的代码,并且无法定位到列。
-
module
:解析loader处理之前的源代码。- 会解析完整的source map,即没有经过loader加工的与源代码一致的代码,因它需要配置在
cheap-
后,所以同样无法定位到列。
- 会解析完整的source map,即没有经过loader加工的与源代码一致的代码,因它需要配置在
根据它们的定义可以理解以下规则:
inline/hidden/nosources/cheap
需要与source-map
一起使用module
需要与cheap
一起使用
使用建议:
[eval/inline]-source-map
会将source map以Data URLs方式嵌入到打包文件中,会使文件变大很多,一般不建议使用。
Source Map 模式选择建议
- 开发环境:eval-cheap-module-source-map
- cheap:每行代码不会太长,只需要定位到行位置即可。
- module:项目中一般都使用了loader,需要查看加工前的代码。
- 通过官方对比表可以看到,这个模式首次启动打包速度慢,但是重写打包速度快,开发中一般使用dev server实现自动编译,所以首次启动打包速度慢无所谓。
- 生产环境:none
- source map会暴露源代码
- 调试是开发阶段的事情
- 如果没有信息预防生产环境报错的情况,建议使用 nosources-source-map,以定位位置又不至于暴露源代码内容。
webpack 自动刷新
webpack dev server 主要为使用webpack构建的项目,提供友好的开发环境,和一个用于调试的开发服务器。
它可以监视到代码的变化,自动打包,最后通过 自动刷新页面 的方式同步到浏览器以便于即时预览。
缺点: 自动刷新浏览器 会导致页面状态丢失。
期望:页面不刷新的前提下,模块也可以及时更新。
webpack HMR 热替换
HMR(Hot Module Replacement):模块热替换 / 模块热更新
计算机行业常见名词「热拔插」:在一个正在运行的机器上随时插拔设备。
- 机器的运行状态不会受插拔设备的影响。
- 插上的设备可以立即开始工作。
例如电脑上的USB端口就是可以热拔插的。
「模块热替换」 中的「热」与「热拔插」中的「热」是一个道理,它们都是在运行过程中的即时变化。
模块热替换 就是 应用运行过程中实时替换某个模块,应用运行状态不受影响。
相对于自动刷新页面丢失页面状态,热替换只将修改的模块实时替换至应用中,不必完全刷新应用。
HMR可以实时更新包括CSS、JS 以及 静态资源的所有模块。
HMR是webpack中最强大、最受欢迎的功能之一。它极大程度的提高了开发者的工作效率。
开启HMR
webpack-dev-server 已经集成了 HMR。
webpack 或 webpack-dev-server 可以通过在运行命令时添加--hot
参数去开启这个特性。
也可以通过在配置文件中配置devServer.hot
为true
开启。
注意:
- 如果通过配置文件启用,则需要配合webpack内置的热替换插件
HotModuleReplacementPlugin
才能完全启用HMR - 如果通过命令行参数
--hot
启用,则会自动添加此插件,而不需要将其添加到webpack.config.js。
HMR 疑问
通过上述启用HMR后发现,修改css文件确实实现了热替换,而修改js文件依然会刷新页面。
这是由于webpack中的HMR并不像其他特性一样开箱即用。
它还需要进行一些额外的操作,才能正常工作。
webpack中的HMR需要通过代码手动处理 模块热替换逻辑 ( 当模块更新后,如何把更新过的模块替换到运行页面中 )。
如果没有手动处理,就会触发自动刷新页面,反之就不会触发自动刷新页面。
Q1. 为什么样式文件的热更新开箱即用?
因为样式文件是通过loader处理的,上例(代码目录08-hmr)中样式文件在style-loader中就已经自动处理了样式文件的热更新。
可通过在开发这工具中查看样式文件的source map,其中使用了处理热替换逻辑的代码:
if (module.hot) {
// ...
module.hot.accept(/*...*/)
// ...
}
Q2. 为什么样式文件可以自动处理,而脚本文件需要手动处理?
因为样式文件变更后,只需要将样式文件的内容替换到页面中,就可以实现样式的即时更新。
而Javascript模块是没有任何规律的:模块可能导出的是一个对象,一个字符串,或者一个函数。
开发中对这些导出的使用方式也是不同的。
所以webpack面对这些毫无规律的JS模块,不知道如何处理当前更新后的模块。也就没有办法实现一个可以通用所有情况的模块替换方案。
Q3. 使用vue-cli或create-react-app创建的项目,没有手动处理,JS照样可以热替换
这是因为项目使用了框架,框架提供了统一的规则,框架下的开发,每种文件都是有规律的。
例如在react中要求每个文件必须导出一个函数或一个类。
有了规律,就可能有一个通用的替换方案。
例如如果每个文件都导出一个函数,就把这个函数拿过来再次执行一次,实现热替换。
另一方面,通过脚手架创建的项目内部已经集成并使用了通用的HMR方案,所以不需要手动处理。
HMR APIs
HotModuleReplacementPlugin 为JS提供了一套用于处理HMR的API。
开发者需要在自己的代码中使用这套API,以处理当某个模块更新后,应该如何替换到当前正在运行的页面中。
module.hot
是HMR API的核心对象。
module.hot.accept(arg1, arg2)
用于注册,当某个模块更新后的处理函数。
arg1
接收一个依赖模块的路径。
arg2
就是依赖模块更新后的处理函数。
if (module.hot) {
module.hot.accept('./editor', () => {
console.log('editor 模块更新了,需要这里手动处理热替换逻辑')
})
}
HMR 注意事项
- 手动处理HMR时,如果处理逻辑的代码中报错导致失败,就会回退到自动刷新页面的方式实现替换。由于自动刷新,处理逻辑代码中的报错信息就不会展示。
- 解决办法:配置
devServer.hotOnly:true
启用不刷新页面的热模块替换,代替devServer.hot:true
。 - 命令行使用:
--hot-only
- 解决办法:配置
- 项目中使用了HMR APIs(
module.hot.accept
),但是并没有配置完全启用HMR。执行时就会报错:Cannot read property 'accept' of undefined
- 这是由于module.hot是内置插件HotModuleReplacementPlugin提供的,未启用HMR(也就是未使用这个插件),
module.hot
就是undefined
- 解决办法:在使用API前先确认下hot是否开启,使用
if (module.hot)
- 这是由于module.hot是内置插件HotModuleReplacementPlugin提供的,未启用HMR(也就是未使用这个插件),
- 代码中写了很多与业务无关的代码(处理热替换的逻辑代码)
- 解决办法:由于生产环境不需要启用HMR,并且在调用HMR APIs前进行了
if(module.hot)
确认,所以生产环境打包后,处理热替换的代码就会编译为if (false) {}
。代码全部清空。
- 解决办法:由于生产环境不需要启用HMR,并且在调用HMR APIs前进行了