11. 工作原理
在项目中一般都会散落着各种各样代码及资源文件,webpack会根据配置找到其中的一个文件作为打包的入口,一般情况这个文件都会是js文件。
然后顺着入口文件中的代码根据代码中出现的import或者require之类的语句解析推断出来这个文件所依赖的资源模块,然后分别解析每个资源模块对应的依赖,最后就形成了整个项目中所有用到文件之间的一个依赖关系的依赖树。
有了依赖关系树后webpack会递归这个依赖树然后找到每个节点对应的资源文件。最后再根据配置文件中的属性找到这个模块所对应的加载器,然后交给加载器去加载这个模块。
最后会将加载到的结果放到bundle.js也就是打包结果中,从而实现整个项目的打包。整个过程中loader的机制起了很重要的作用,如果没有loader就没办法实现各种各样的资源文件的加载,对于webpack来说也就只能算是一个用来去打包或是合并js模块代码的工具了。
12. 开发一个Loader
markdown-loader,需求是有了这个加载器后就可以在代码当中直接导入markdown文件。
main.js
import about from './about.md';
console.log(about);
about.md
# 关于我
我是隐冬
markdown文件一般是要被转换为html后呈现到页面上的,所以说这里希望导入的markdown文件得到的结果是markdown转换过后的html字符串。
在项目的根目录创建markdown-loader.js文件,webpack-loader需要去导出一个函数,这个函数就是loader对所加载到资源的处理过程,入参是加载到的资源文件的内容,输出是加工过后的结果。通过source接收输入,通过返回值输出。
module.exports = source => {
console.log(source);
return 'hello';
}
在webpack的配置文件中添加加载器的规则配置,扩展名就是.md使用的加载器是我编写的markdown-loader模块。
const path = require('path');
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
publicPath: 'dist/'
},
module: {
rules: [
{
test: /.md$/,
use: './markdown-loader'
}
]
}
}
webpack加载资源的过程类似工作管道,可以在这个过程中依次使用多个loader,但是最终这个管道工作过后的结果必须是一段javascript代码,markdow-loader中,将返回的字符串修改为console.log("hello")标准的js代码。
module.exports = source => {
console.log(source);
return 'console.log("hello")';
}
webpack打包的时候就是把loader返回的字符串拼接到模块当中了。
/* 1 */
/***/ (function(module, exports) {
console.log("hello")
/***/ })
安装markdown解析的模块marked。
yarn add marked --dev
在加载器当中使用这个模块去解析来自参数中的source,这里返回值就是一段html字符串也就是转换过后的结果。正确的做法就是把这段html变成一段javascript代码。
const marked = require('marked');
module.exports = source => {
// console.log(source);
// return 'console.log("hello")';
const html = marked(source);
return `module.exports = ${JSON.stringify(html)}`
}
打包后就是下面的样子。
/* 1 */
/***/ (function(module, exports) {
module.exports = "<h1 id=\"关于我\">关于我</h1>\n<p>我是隐冬</p>\n"
/***/ })
除了module.exports方式以外webpack还允许在返回的代码中直接使用ES Module的方式去导出。
const marked = require('marked');
module.exports = source => {
// console.log(source);
// return 'console.log("hello")';
const html = marked(source);
// return `module.exports = ${JSON.stringify(html)}`
return `export default ${JSON.stringify(html)}`
}
打包结果同样也是可以的,webpack内部会自动转换导出代码中的ES Module。
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony default export */ __webpack_exports__["default"] = ("<h1 id=\"关于我\">关于我</h1>\n<p>我是隐冬</p>\n");
/***/ })
接下来尝试一下第二种方法,markdown-loader中去返回一个html字符串。然后交给下一个loader去处理这个html的字符串。这里直接返回marked解析过后的html,然后再去安装一个用于去处理html加载的loader叫做html-loader。
const marked = require('marked');
module.exports = source => {
// console.log(source);
// return 'console.log("hello")';
const html = marked(source);
// return `module.exports = ${JSON.stringify(html)}`
// return `export default ${JSON.stringify(html)}`
return html;
}
yarn add html-loader --dev
把use属性修改为一个数组,这样就会依次使用多个loader了。需要注意执行顺序是从数组的后面往前面,也就是说应该把先执行的loader放在数组的后面。
const path = require('path');
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
publicPath: 'dist/'
},
module: {
rules: [
{
test: /.md$/,
use: ['html-loader', './markdown-loader']
}
]
}
}
完成后打包依然是可以的。
/* 1 */
/***/ (function(module, exports) {
module.exports = "<h1 id=\"关于我\">关于我</h1>\n<p>我是隐冬</p>\n"
/***/ })
13. 插件机制介绍
插件机制是webpack另一个核心特性,目的是为了增强webpack在项目自动化方面的能力,loader负责实现项目中各种各样资源模块的加载,plugin则是用来解决项目中除了资源加载以外其他的一些自动化的工作。
例如plugin可以实现在打包之前清除dist目录,还可以copy不需要参与打包的资源文件到输出目录,又或是压缩打包结果输出的代码。总之,有了插件webpack几乎无所不能的实现了前端工程化中绝大多数工作,这也是很多初学者会把webpack理解成前端工程化的原因。
接下来体验几个常见的插件。
1. clean-webpack-plugin
自动清除输出目录,webpack每次打包的结果都是覆盖到dist目录而在打包之前dist中可能已经存在一些之前的遗留文件,再次打包可能只覆盖那些同名的文件,对于其他已经移除的资源文件会一直积累在dist里面非常不合理。合理的做法是每次打包前自动清理dist目录,这样的话dist中就只会保留需要的文件。
clean-webpack-plugin就很好的实现了这样一个需求。
yarn add clean-webpack-plugin --dev
webpack配置文件中导入这个插件,插件中导出了一个CleanWebpackPlugin的成员可以解构出来,webpack使用插件需要为配置对象添加plugins属性,值是一个数组里面每一个成员就是一个插件实例。绝大多数插件模块导出的都是一个类型,使用插件就是通过类型创建实例,然后将实例放入到plugins数组中。
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
module.exports = {
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
publicPath: 'dist/'
},
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: 'file-loader'
}
]
},
plugins: [
new CleanWebpackPlugin()
]
}
2. html-webpack-plugin
在之前html都是通过硬编码的方式单独存放在项目的跟目录下的这种方式有两个问题,第一项目发布时需要发布跟目录下的html文件和dist目录下所有的打包结果,相对麻烦一些。而且上线过后还需要确保html代码当中路径引用都是正确的。第二个如果输出的目录或输出的文件名也就是打包结果的配置发生了变化,那html代码当中script标签所引用的路径也要手动修改。
解决这两个问题最好的办法就是通过webpack自动生成html文件,也就是让html参与到构建过程中去,在构建过程中webpack知道生成了多少个bundle,会自动将这些打包的bundle添加到页面中。这样html也输出到了dist目录,上线时只需把dist目录发布出去就可以了。二来html中对于bundle的引用是动态注入的,不需要硬编码也就确保了路径的引用是正常的。
需要借助html-webpack-plugin插件。
yarn html-webpack-plugin --dev
配置文件中载入这个模块html-webpack-plugin默认导出的就是一个插件的类型,不需要解构他内部的成员。
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
publicPath: 'dist/'
},
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: 'file-loader'
}
]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin()
]
}
打包过后dist目录中生成了index.html文件,这里引入的bundle.js路径是可以通过output属性中的publicPath进行修改的,可以删除这个配置。这样打包之后index.html中bundle的引用就标成了/bundle.js。
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
// publicPath: 'dist/'
},
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: 'file-loader'
}
]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin()
]
}
对于默认生成的html的标题是可以修改的,页面中的一些原数据标签和基础的DOM结构也是可以定义的,对于简单的自定义可以通过修改html-webpack-plugin插件传入的参数属性实现。html-webpack-plugin构造函数可以传入一个对象参数,用于指定配置选项,title属性就是用来设置html的标题。meta属性可以设置页面中的一些原数据标签。
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
// publicPath: 'dist/'
},
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: 'file-loader'
}
]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'Webpack Plugin Sample',
meta: {
viewport: 'width=device-width'
}
})
]
}
如果需要对html文件进行大量自定义,最好的做法就是在原代码中添加一个用于生成html文件的一个模板,然后让html-webpack-plugin插件根据模板生成页面。
对于模板中动态输出的内容可以使用loadsh模板语法的方式去输出。通过htmlWebpackPlugin.options属性去访问到插件的配置数据,htmlWebpackPlugin变量实际上是内部提供的变量,也可以通过另外的属性添加一些自定义的变量。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<script src="dist/"></script>
</body>
</html>
配置文件当中通过template属性指定所使用的模板文件。
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
// publicPath: 'dist/'
},
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: 'file-loader'
}
]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'Webpack Plugin Sample',
meta: {
viewport: 'width=device-width'
},
template: './src/index.html'
})
]
}
除了自定义输出文件的内容,同时输出多个页面文件也是常见的需求,可以通过创建新的实例对象,用于去创建额外的html文件,通过filename指定输出的文件名,这个属性的默认值是index.html。
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
// publicPath: 'dist/'
},
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: 'file-loader'
}
]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'Webpack Plugin Sample',
meta: {
viewport: 'width=device-width'
},
template: './src/index.html'
}),
new HtmlWebpackPlugin({
filename: 'about.html'
})
]
}
如果说需要创建多个页面,就可以在插件列表当中加入多个htmlWebpackPlugin实例的对象,每个对象就是用来负责生成一个页面文件的。
3. copy-webpack-plugin
项目中一般有一些不需要参与构建的静态文件最终也需要发布到线上,例如网站的favicon.ico,一般会把这一类文件统一放在项目根目录下的public目录中,希望webpack在打包时可以将他们复制到输出目录。对于这种需求可以借助copy-webpack-plugin实现。
yarn add copy-webpack-plugin --dev
配置文件当中导入这个插件的类型并在plugins属性当中添类型实例,这个这个类型的构造函数要求传入一个数组,用于指定需要copy的文件路径,可以是一个通配符,也可以是一个目录或者是文件的相对路径。这里传入public/**表示在打包时会将public目录下所有的文件拷贝到输出目录。
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
module.exports = {
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
// publicPath: 'dist/'
},
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: 'file-loader'
}
]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'Webpack Plugin Sample',
meta: {
viewport: 'width=device-width'
},
template: './src/index.html'
}),
new HtmlWebpackPlugin({
filename: 'about.html'
}),
new CopyWebpackPlugin([
'public/**'
])
]
}
至此就了解了几个非常常用的插件,这些插件一般都适用于任何类型的项目,最好能仔细过一遍这些插件的官方说明,然后看看他们还可以有哪些特别的用法,做到心中有数。
除此之外社区当中还提供了成百上千的插件,并不需要全部认识,在有一些特殊的需求时,提炼需求中的一些关键词然后去github上去搜索他们,例如想要压缩输出的图片可以搜索imagemin webpack plugin。
14. 开发一个插件
webpack的插件机制其实就是软件开发过程中最长见到的钩子机制。webpack要求的插件必须是一个函数,或者是一个包含apply方法的对象,一般会把插件定义为一个类型,在类型中定义apply方法。
这里定义MyPlugin的类型,在这个类型中定义apply方法,这个方法会在webpack启动时被调用,接收一个compiler对象参数就是webpack工作过程中的核心对象,对象里面包含了此次构建的所有的配置信息,通过这个对象可以注册钩子函数。
这里的需求是希望这个插件可以用来去清除webpack打包生成的js中没必要的注释,有了这个需求需要明确这个任务的执行时机,也就是要把这个任务挂载到哪个钩子上。
需求是删除bundle.js中的注释,也就是说当bundle.js文件内容明确后才可以实施相应的动作,在webpack的官网的API文档中找到emit的钩子,这个钩子在webpack即将要往输出目录输出文件时执行。
通过compiler当的hooks属性访问到emit钩子,然后通过tap方法注册钩子函数,这个方法接收两个参数,第一个参数是插件的名称MyPlugin,第二个是需要挂载到这个钩子上的函数。在函数中接收一个complation的对象参数,这个对象可以理解成此次打包过程的上下文。
所有打包过程中产生的结果都会放到这个对象中,使用对象的assets属性获取即将写入到目录文件中的资源信息complation.assets。这是一个对象通过for in遍历这个对象,对象当中的键就是每一个文件的名称。然后将这个插件应用到配置当中通过 new MyPlugin的方式把他应用起来。
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
class MyPlugin {
apply(compiler) {
console.log('MyPlugin 启动');
compiler.hooks.emit.tap('MyPlugin', complation => {
for (const name in complation.assets) {
console.log(name);
}
})
}
}
module.exports = {
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
// publicPath: 'dist/'
},
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: 'file-loader'
}
]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'Webpack Plugin Sample',
meta: {
viewport: 'width=device-width'
},
template: './src/index.html'
}),
new HtmlWebpackPlugin({
filename: 'about.html'
}),
new CopyWebpackPlugin([
'public/**'
]),
new MyPlugin()
]
}
此时打包过程就会输出打包的文件名称,可以通过文件中值的source方法来获取文件内容。
class MyPlugin {
apply(compiler) {
console.log('MyPlugin 启动');
compiler.hooks.emit.tap('MyPlugin', complation => {
for (const name in complation.assets) {
console.log(assets[name].source());
}
})
}
}
拿到文件名和内容后要判断文件是否以.js结尾,如果是js文件将文件的内容得到然后通过正则的方式替换掉代码当中对应的注释,将替换的结果覆盖到原有的内容当中,要覆盖complation当中assets里面所对应的属性。这个属性的值同样暴露一个source方法用来去返回新的内容。除此之外还需要一个size方法,用来返回这个内容的大小,这个是webpack内部要求的必须的方法。
class MyPlugin {
apply(compiler) {
// console.log('MyPlugin 启动');
compiler.hooks.emit.tap('MyPlugin', complation => {
for (const name in complation.assets) {
// console.log(assets[name].source());
if (name.endsWith('.js')) {
const contents = complation.assets[name].source();
const withoutComments = contents.replace(/\/*\**\*\//g, '');
complation.assets[name] = {
source: () => withoutComments,
size: () => withoutComments.length
}
}
}
})
}
}
打包过后bundle.js每一行开头的注释就被移除掉了,以上就是实现移除webpack注释插件的过程,通过这个过程了解,插件是通过往webpack生命周期里面的一些钩子函数里面挂载任务函数来去实现的。如果需要深入了解插件机制,可能需要理解一些webpack底层的实现原理,通过去阅读源代码来了解他们。
15. 开发体验问题
在此之前已经了解了一些webpack的相关概念和一些基本的用法,但是以目前的状态去应对日常的开发工作还远远不够,编写源代码再通过webpack打包然后运行应用,最后刷新浏览器这种方式过于原始。如果实际的开发过程中还按照这种方式去使用必然会大大降低开发效率。
希望开发环境必须能够使用http的服务运行而不是以文件的形式预览,这样一来可以更加接近生产环境的状态,而且使用ajax之类的一些api也需要服务器环境。其次希望这个环境在修改源代码后webpack可以自动完成构建,然后浏览器可以及时的显示最新的结果,这样的话就可以大大的减少在开发过程中额外的重复操作。
最后还需要能够去提供sourceMap支持,运行过程中一旦出现了错误就根据错误的堆栈信息快速定位到源代码当中的位置,便于调试应用。
1. 自动编译
用命令行手动重复去使用webpack命令从而去得到最新的打包结果,这种办法特别的麻烦可以使用webpack-cli提供的watch工作模式解决这个问题。这种模式项目下的源文件会被监视,一旦这些文件发生变化,会自动重新运行打包任务。
用法非常简单,就是启动webpack时添加--watch参数。
yarn webpack --watch
可以再开启一个新的命令行终端以http的形式运行应用。
http-server ./dist
此时修改源代码webpack就会自动重新打包,可以刷新页面看到最新的页面结果。
2. 自动刷新浏览器
如果流浏览器能在编译过后自动去刷新,开发体验将会更好一些,browser-sync工具就会实现自动刷新的功能。
yarn add --global browser-sync
使用browser-sync启动http服务同时要监听dist文件下的文件变化。此时修改源文件保存过后浏览器会自动刷新然后显示最新的结果。
browser-sync dist --files "**/*"
原理是webpack自动打包源代码到dist当中,dist的文件变化被browser-sync监听从而实现了自动编译并且自动刷新浏览器。
3. 开发服务器
Webpack Dev Server提供了一个开发服务器,并且将自动编译和自动刷新浏览器等一系列对开发友好的功能全部集成在了一起。这是一个高度集成的工具使用起来非常的简单。
yarn add webpack-dev-server --dev
yarn webpack-dev-server
运行命令内部会自动使用webpack打包我应用并且会启动一个http-server去运行打包结果。还会监听代码变化,一旦源文件发生变化就会自动打包,这一点和watch模式是一样的。
webpack-dev-server为了提高工作效率并没有将打包结果写入到磁盘中,是将打包结果暂时存放在内存中,内部的http-server也是从内存中把这些文件读出来发送给浏览器,这样一来就会减少很多磁盘不必要的读写操作,从而大大提高构建效率。
这个命令可以传入--open参数,用于自动唤起浏览器,打开运行地址。
yarn webpack-dev-server --open
如果有两块屏幕就可以把浏览器放到另外一块屏幕中,一边编码,一边及时预览开发环境。
4. 静态资源访问
静态文件需要作为开发服务器的资源被访问需要额外的去告诉webpack-dev-server,具体的方法就是在webpack的配置文件当中添加对应的配置。在配置对象当中添加dev-server的属性这个属性专门用来为webpack-dev-server指定相关的配置选项。
配置对象的contentBase属性用来指定静态资源路径,可以是一个字符串或者是一个数组,也就是说可以配置一个或者是多个路径,这里设置为public目录。
之前通过copy-webpack-plugin插件将public目录输出到了输出目录,正常所有输出的文件都应该可以直接被server也就是直接在浏览器端访问到。按道理来讲这些文件不需要再作为开发服务器的额外的资源路径了,但是在实际使用webpack的时候一般都会把copy-webpack-plugin这插件留在上线前的那次打包中。在平时的开发过程中一般不会去使用它,因为在开发过程中会重复执行打包任务。假设copy的文件比较多或者是比较大,每次执行插件的话打包过程中的开销就会比较大速度自然也就会降低了。
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
// const CopyWebpackPlugin = require('copy-webpack-plugin');
module.exports = {
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist')
},
devServer: {
contentBase: './public',
},
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: 'file-loader'
}
]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'Webpack Plugin Sample',
meta: {
viewport: 'width=device-width'
},
template: './src/index.html'
}),
new HtmlWebpackPlugin({
filename: 'about.html'
}),
// new CopyWebpackPlugin(['public'])
]
}
5. API代理
webpack-dev-server在启动服务时创建的是一个本地服务,访问地址一般为localhost:端口号,而最终上线过后应用一般又和API会部署到同源地址下面。这样的话就会有一个非常常见的问题,在实际生产环境当中可以直接访问API,开发环境中就会产生跨域请求问题。
解决这个问题最好的办法就是在开发服务器配置代理服务也就是把接口服务代理到本地的开发服务地址,webpack-dev-server支持通过配置的方式添加代理服务。
这里的目标就是将github的api代理到本地的开发服务器当中。github的接口的Endpoint一般都是在根目录下,例如这里所使用的user
https://api.github.com/users
Endpoint可以理解为接口入口,回到配置文件当中,在devServer当中添加proxy属性,这个属性就是专门用来添加代理服务配置的,是个对象,每一个属性就是一个代理规则的配置,属性的名称是需要被代理的请求路径前缀,也就是请求以哪一个地址开始,一般为了辨别会将其设置为/api,也就是请求开发服务器中的/api开头的这种地址都会让他代理到接口当中。
将代理目标设置为https://api.github.com, 也就是说当请求/时代理目标就是api.github.com地址。
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
// const CopyWebpackPlugin = require('copy-webpack-plugin');
module.exports = {
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist')
},
devServer: {
contentBase: './public',
proxy: {
'/api': {
target: 'https://api.github.com'
}
}
},
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: 'file-loader'
}
]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'Webpack Plugin Sample',
meta: {
viewport: 'width=device-width'
},
template: './src/index.html'
}),
new HtmlWebpackPlugin({
filename: 'about.html'
}),
// new CopyWebpackPlugin(['public'])
]
}
此时如果去请求http://localhost:8080/api/users就相当于请求了https://api.github.com/api/users,意思是请求的路径是什么,最终代理的这个地址路径是完全一致的。
实际上在api.github.com/users中并没有/api/users所以对于代理路径中的/api需要去掉,可以添加pathRewrite属性,来去实现代理路径的重写。重写规则就是把路径中以/api开头的这段字符串替换为空字符串,pathRewrite会以正则的方式替换请求的路径。
还需要设置changeOrigin属性为true,请求的过程中会带一个主机名这个主机名默认情况下使用的是用户在浏览器端发起请求的这个主机名也就是localhost:8080。一般情况下服务器那头是要根据主机名去判断这个请求是属于哪个网站从而把这个请求指派到对应的网站。localhost:8080对于github的服务器来说是不认识的,所以这里需要changeOrigin去修改修改。changeOrigin=true会以代理请求的主机名去请求。请求github这个地址真正请求的应该是api.github.com这样地址,所以主机名会保持原有状态。
{
contentBase: './public',
proxy: {
'/api': {
target: 'https://api.github.com'.
pathRewrite: {
'^/api': ''
},
changeOrigin: true
}
}
}
针对于changeOrigin可能不会特别清楚,这是因为在http里面有一些相关的知识点可能之前没有了解过,可以查一下host也就是主机名相关的概念就可以理解了。
16. Source Map
通过构建编译之类的操作可以将开发阶段的源代码转换为能够在生产环境中运行的代码,但是这种进步的同时也意味着在实际生产环境中运行的代码与开发阶段所编写的代码之间会有很大的差异。在这种情况下如果需要调试应用,又或是运行应用的过程中出现了意料之外的错误,将无从下手。Source Map就是解决这一类问题最好的办法。
Source Map是用来映射转换过后的代码与源代码的关系,一段转换过后的代码通过转换过程中生成的这个source map文件可以逆向得到源代码。目前很多第三方库中都会有一个.map后缀的source map文件,这是一个JSON格式的文件里面记录的就是转换过后的代码与转换之前代码之间的映射关系。主要有version属性指的是当前文件所使用的的source map标准的版本。然后是source属性,记录的是转换之前源文件的名称,因为很有可能是多个文件合并转换成了一个文件,所以说属性是一个数组。
names属性指的是源代码中使用的一些成员名称,在压缩代码时会将开发阶段编写的有意义的变量替换为一些简短的字符从而去压缩整体代码的体积,这个属性中记录的就是原始对应的名称。mappings属性是整个source map文件的核心属性,是一个base64-vl编码的字符串。这个字符串记录的信息就是转换过后代码中的字符与转换之前所对应的映射关系。
{
"version": 3,
"file": "jquery.min.js",
"sources": [
"jquery.js"
],
"names": [
"window",
"undefined",
"readyList",
"rootjQuery",
"core_strundefined",
"location",
"document",
"docElem",
"documentElement",
"_jQuery",
"jQuery",
"_$",
"$",
"class2type"
...
],
"mappings": ";;;CAaA,SAAWA,EAAQC,GAOnB,GAECC,GAGAC,EAIAC,QAA2BH,GAG3BI,EAAWL,EAAOK,SAClBC,EAAWN,EAAOM,SAClBC,EAAUD,EAASE,gBAGnBC,EAAUT,EAAOU,OAGjBC,EAAKX,EAAOY,EAGZC,KAGAC,
...
"
}
有了这个文件后一般会在转换过后的代码当中通过注释的方式引入这个source map文件。source map特性只是帮助开发者更容易调试和定位错误所以对生产环境其实没有什么太大的意义。
//# sourceMappingURL = jquery-3.4.1.min.map
在浏览器中如果打开了开发人员工具加载到的这个js文件最后发现这行注释就会自动请求这个source map文件。然后根据这个文件的内容逆向解析出对应的源代码以便于调试,同时因为有了映射的关系所以说源代码当中如果说出现了错误也很容易定位到源代码当中对应的位置。
1. 配置source map
webpack可以为打包结果生成对应的source map文件,需要使用devTool属性就是用来配置开发过程中的辅助工具,也就是与source map 相关的一些功能配置。
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
module.exports = {
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
},
devtool: 'source-map',
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: 'file-loader'
}
]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'Webpack Plugin Sample',
meta: {
viewport: 'width=device-width'
},
template: './src/index.html'
}),
new HtmlWebpackPlugin({
filename: 'about.html'
}),
new CopyWebpackPlugin([
'public/**'
])
]
}
可以直接将这个属性设置为source-map,打包完成过后生成的dist目录可以发现生成了一个对应的bundle.js.map文件。而且bundle.js文件的最后也通过注释的方式引入了这个source-map文件。
/******/ (function(modules) { // webpackBootstrap
/******/ })
/************************************************************************/
/******/ ([
/******/ ]);
//# sourceMappingURL=bundle.js.map
截止到目前webpack对source map的风格支持了12种,每种方式所生成的source map 效果以及生成source map的速度,都是不一样的。很简单也很明显的一个道理就是效果最好的生成速度也就会最慢。而速度最快的生成出来的这个source map文件也就没有什么效果。
2. eval
eval是js的函数,可以用来运行字符串中的js代码。
eval('console.log(123)');
默认情况下这段代码会运行在一个临时的虚拟机环境中。可以通过source url声明这段代码所处的文件路径。比如添加一段注释内容就是//# sourceURL=./foo/bar,此时这段代他所运行的这个环境就是./foo/bar.js。这也就意味着可以通过sourceURL改变通过eval执行的这段代码所处的环境名称,其实还是运行在虚拟机环境中,只不过他告诉了执行引擎这段代码所属的文件路径,这里只是一个标识而已。
eval('console.log(123) //# sourceURL=./foo/bar.js');
了解了这个特点回到配置文件中将devtool属性设置为eval,也就是使用eval模式。找到错误所出现的文件,打开这个文件到的却是打包过后的模块代码。因为在这种模式下,会将每个模块所转换过后的代码都放在eval函数中去执行,并且在eval函数执行的字符串最后通过sourceURL的方式去说明所对应的文件路径。
这样的话浏览器在通过eval执行这段代码的时候就知道这段代码所对应的源代码是哪一个文件从而实现定位错误所出现的文件,并且也只能定位文件。
这种模式下不会生成source map文件,也就是说,它实际上跟source-map没有什么太大的关系,所以说他的构建速度也就是最快的效果也就很简单,只能定位源代码文件的名称不知道具体的行列信息。
3. devtool
在main.js当中故意加入了一个运行时的错误console.log111。
import createHeading from './heading.js'
const heading = createHeading();
document.body.append(heading);
console.log('main.js running');
console.log111('main.js running');
打开webpack的配置文件定义一个数组,数组中的每一个成员就是devtool配置取值的一种。
const allModes = [
'eval',
'cheap-eval-source-map',
'cheap-module-eval-source-map',
'eval-source-map',
'cheap-module-source-map',
'inline-cheap-source-map',
'inline-cheap-module-source-map',
'source-map',
'inlie-source-map',
'hidden-source-map',
'nosource-source-map',
]
webpack的配置对象可以是一个数组,数组中给的每个元素就是一个单独的打包配置。这样一来就可以在一次打包过程中同时执行多个打包任务。可以通过遍历为每一种模式单独去创建一个打包配置,这样的话就可以在一次打包中同时生成所有模式下的不同结果,方便对比使用。
每个配置项中先定义devtool属性,属性的值就是遍历的名称。将mode设置为none确保webpack内部不会去做额外的处理,紧接着设置任务的打包入口,以及输出文件的名称。这里将输出文件的名称设置为模式名称命名的js文件。再下面为js模块配置babel-loader,目的是接下来对比中能够辨别其中一类模式的差异。最后再配置一个html-webpack-plugin,也就是为每个打包任务生成一个html文件。
const HtmlWebpackPlugin = require('html-webpack-plugin');
const allModes = [
'eval',
'cheap-eval-source-map',
'cheap-module-eval-source-map',
'eval-source-map',
'cheap-module-source-map',
'inline-cheap-source-map',
'inline-cheap-module-source-map',
'source-map',
'inlie-source-map',
'hidden-source-map',
'nosource-source-map',
];
module.exports = allModes.map(item => {
return {
mode: 'none',
devtool: item,
entry: './src/main.js',
output: {
filename: `js/${item}.js`,
},
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
}
]
},
plugins: [
new HtmlWebpackPlugin({
filename: `${item}.html`
})
]
}
})
打包过后就会生成所有模式下的打包结果,启动服务打开浏览器此时就能在页面中看到所有不同模式下的html。有了这些不同模式下的打包结果就可以一个一个仔细对比。
1. eval。
这个模式就是将模块代码放到eval函数中执行,并且通过sourceURL标注模块文件的路径。这种模式下并没有生成对用的source-map只能定位是哪一个文件出了错误。
2. eval-source-map
这个模式同样也是使用eval函数执行模块代码,不同的是除了可以定位错误出现的文件。还可以定位到具体的行列信息,这种模式下相比于eval生成了source-map。
3. cheap-eval-source-map
这个模式就是在eval-source-map的基础之上加了一个cheap,就是阉割版的source-map。虽然也生成了source-map,但这种模式下的source-map只能定位到行而没有列的信息。少了一点效果所以生成速度自然就会快很多。
4. cheap-module-eval-source-map
这其是cheap-eval-source-map模式基础上多了一个module,cheap-module-eval-source-map定位源代码跟编写的源代码是一模一样的。而cheap-eval-source-map显示的是经过babel转换过后的结果。如果想要和手写代码一样的源代码,需要选择cheap-module-eval-source-map这种模式。
5. cheap-source-map
没有eval就爱意味着没有用eval的方式执行模块代码,没有module也就意味着是loader处理过后的代码。
6. inline-source-map
和普通的source-map效果上是一样的,只不过source-map的模式下文件是以物理文件的方式存在,而inline-source-map使用的是dataurl的方式去将source-map以dataurl嵌入到代码中。之前遇到的eval-source-map其实也是使用这种行内的方式把source-map嵌入进来,会导致这个代码的体积会变大很多。
7. hidden-source-map
这个模式在开发工具中是看不到source-map的效果的。但是回到开发工具中会发现确实生成了source-map文件。这就跟jq是一样的,在构建过程当中,生成了source-map文件,但是他在代码当中并没有通过注释的方式去引入这个文件。
这个模式实际上是在开发一些第三方包的时候比较有用,需要生成source-map但是不想在代码当中直接去引用他们,一旦在使用时出现了问题,可以再把source-map引入回来,或者通过其他的方式使用source-map。
source-map还有很多其他的使用方式,通过http的响应头也可以使用,这里就不再扩展了。
8. nosources-source-map
这个模式下能看到错误出现的位置,但是点击错误信息是看不到源代码的。nosource指的就是没有源代码,但是提供了行列信息,这样结合编写的源代码还是可以找到错误出现的位置。只是在开发工具当中看不到源代码而已。这是为了在生产环境中保护源代码不会被暴露的一种情况。
4. 选择 Source Map
虽然webpack支持各种各样的SourceMap模式,但是一般应用开发时也只会用到其中的几种。
1. 开发模式
在开发环境建议选择cheap-module-eval-source-map,一般编写代码的风格要求每一行代码不会超过80个字符,能够定位到行也就够了因为每一行里面最多也就80个字符,很容易找到对应的位置。第二就是使用框架的情况会比较多,以react和vue来说,无论是使用jsx还是vue的单文件组件,loader转换过后的代码和转换之前都会有很大的差别,这里需要调试转换之前的源代码,而不是转换过后的,所以要选择有module的方式。
第三点就是虽然cheap-module-eval-source-map的启动速度会比较慢一些,但是大多说时间我都是在使用webpack-dev-server监视模式重新打包,而不是每次都启动打包所以这种模式下重新打包速度比较快。
2. 生产模式
生产环境的打包交易选择选择none也就是不生成source-map。source-map会暴露源代码到生产环境,这样的话但凡有一点技术的人都可以很容易复原项目中绝大多数的源代码,这个点是要注意的。
其次调试和找错误这些都应该是开发阶段的事情,应该在开发阶段就尽可能把所有的问题和隐患都找出来,而不是到了生产环境让全民去公测。
如果对代码实在没有信心建议选择nosources-source-map模式,这样出现错误在控制台当中就可以找到源代码对应的位置,不至于向外暴露源代码内容。
17. HMR
实际开发中自动刷新并没有想象中那么好用因为每次修改完代码,webpack监视到文件变化就会自动打包,然后自动刷新到浏览器,一旦页面整体刷新,那页面中之前的任何操作状态都会丢失,有些人一般会有一些小办法,例如可以在代码中写死一个文本到编辑器中。这样的话即便页面刷新也不会有丢失的情况,又或是通过一些额外的代码,把内容先保存到临时存储中,然后刷新过后再取回来。
这些都是好办法但是又都不是特别的好,因为这些都是典型的有洞补洞的操作,并不能根治页面刷新过后导致的页面数据丢失的问题。而且这些方法都需要编写一些跟业务本身无关的代码,更好的办法自然是能够在页面不刷新的这种情况下代码也可以及时的更新。针对这样的需求webpack给出了解决方案。
HRM(Hot Module Replacement)翻译过来叫做模块热替换或者叫模块热更新。计算机行业经常听到一个叫做热拔插的名词,指的就是可以在正在运行的机器上随时插拔设备而机器的运行状态不会受插拔设备的影响。例如电脑设备上的USB端口。
webpack中的模块热替换指的就是可以在应用程序运行的过程中实时的替换掉应用中的某个模块。而应用的运行状态不会因此改变。HMR是webpack中最强大的特性之一,同时也是最受欢迎的特性。
1. 开启 HMR
HMR已经集成在了webpack-dev-server工具中,使用这个特性需要运行webpack-dev-server命令时通过--hot参数开启,也可以在配置文件中添加对应的配置来开启。
打开配置文件需要配置的地方有两个,第一个将dev-server中的hot属性设置为true。第二是载入一个webpack内置的插件hot-module-replacement-plugin。
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development'
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
// publicPath: 'dist/'
},
devtool: 'source-map',
devServer: {
hot: true
}
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: 'file-loader'
}
]
},
plugins: [
new HtmlWebpackPlugin({
title: 'Webpack Plugin Sample',
template: './src/index.html'
}),
new webpack.HotModuleReplacementPlugin()
]
}
回到开发工具当中修改样式文件,保存过后样式模块就以热更新的方式直接作用到页面中了。但是修改js模块页面还是自动刷新的并没有热更新的体验。
样式文件在style-loader里面就已经自动处理了热更新,不需要额外做手动的操作。而js文件比较复杂,在一个模块中导出的可能是对象,也可能是字符串,还有可能是函数,导出的成员在使用上可能各不相同的。所以webpack对毫无规律的js模块,并不知道如何处理更新过后的模块,也就没有办法实现通用的替换方案。webpack中的HMR需要手动通过代码处理当js模块更新过后需要如何把更新后的模块替换到运行的页面当中。
可能对使用过vue-cli或create-react-app脚手架工具的人来说,会觉得项目中并没有手动处理js模块的更新,代码照样可以做热替换,这是因为使用框架开发时,项目中每个文件有规律,框架提供的是一些规则,例如react模块要求每一个模块必须要导出一个函数,或者是导出一个类。有了这个规律就可能会有通用的替换办法,例如每一个文件导出的都是一个函数的话那就自动把这个函数执行一下。这些工具内部都已经提供好了通用的HMR替换模块,不需要自己手动处理。
综上还需要自己手动处理当js模块更新过后需要做的事情。
Hot-module-replacement-plugin为js提供了一套用于处理HMR的api,需要在自己的代码中使用这套api来去处理当某个模块更新后应该如何替换。
打开入口文件,这个文中使用了导入的模块,一但导入的模块更新就必须重新使用这些模块。
module对象有个hot的对象属性是HMR API的核心对象,提供了一个accept方法用于注册某个模块更新过后的处理函数,方法的第一个参数接收的是依赖模块的路径,第二个参数是更新过后的处理函数。这里注册一下当editor模块更新过后的处理函数。。
import createEditor from './editor';
import background from './better.png';
import './global.css';
const editor = createEditor();
document.body.appendChild(editor);
const img = new Image();
img.src = background;
document.body.appendChild(img);
module.hot.accept('./editor', () => {
console.log('editor 模块更新了,需要这里手动处理');
});
启动webpak-dev-server当修改editor模块代码的时候浏览器的控制台中就会打印消息,而且也不会自动刷新了,也就是说一旦这个模块的更新被手动的处理了,就不会触发自动刷新。
editor模块导出的是一个函数,而且这这个函数是创建界面的元素,一但这个函数更新了,界面元素也应该被重新创建。所以这里先直接移除原来的元素,然后再调用createEditor创建新的元素追加到页面中。
这里还需要保留之前的状态,在更新之后将状态回填进去,因为这里用的是一个可编辑元素,可以通过innerHTML拿到之前所添加的内容,然后创建新元素过后再把它设置到新元素当中。
let lastEditor = editor;
module.hot.accept('./editor', () => {
// console.log('editor 模块更新了,需要这里手动处理');
// console.log(createEditor);
const value = lastEditor.innerHTML;
document.body.removeChild(lastEditor);
const newEditor = createEditor();
newEditor.innerHTML = value;
document.body.appendChild(newEditor);
lastEditor = newEditor;
});
这就是针对于js模块热替换的一个处理过程,注意这不是一个通用的方式,这只适用于当前的editor.js模块。通过这个过程能够发现为什么webpack的HMR需要自己处理js模块的热更新,因为不同的模块有不同的逻辑,不同的业务逻辑又导致处理过程肯定也是不同的,webpack并没有办法提供一个通用的替换方案。
图片模块的热替换逻辑就简单的多,同样通过module.hot.accept方法注册处理函数,在函数中只需要将图片元素的src设置为新的图片路径就可以了。
在图片修改过后图片名会发生变化,这里拿到的是更新之后的文件名。所以直接重新设置图片元素的src就可以实现图片的热替换。
2. 注意事项
如果处理热替换的代码有错误是不容易发现的,错误结果会导致页面自动刷新,自动刷新过后页面中的错误信息也会被清除了,这样一来就不容易发现到底是哪里出错了。
这种情况推荐使用hot only的方式来解决,因为默认使用的hot如果热替换失败会回退使用自动刷新功能,hot only则不会。
配置文件中将dev-server中的hot: true修改为hotOnly: true。
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development'
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
// publicPath: 'dist/'
},
devtool: 'source-map',
devServer: {
hotOnly: true
}
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
},
{
test: /.png$/,
use: 'file-loader'
}
]
},
plugins: [
new HtmlWebpackPlugin({
title: 'Webpack Plugin Sample',
template: './src/index.html'
}),
new webpack.HotModuleReplacementPlugin()
]
}
如果在代码中使用了HMR提供的API,但是在启动dev-server的时候没有开启HMR的选项,此时运行环境中会报出accept undefined的错误。因为module.hot对象是HMR插件提供的,没有开启这个插件也就没有这个对象。解决的办法非常简单,在业务代码应该先判断是否存在module.hot对象再去使用它。
可能会有个疑问,代码当中写了很多与业务功能本身无关的代码会不会有影响。webpack是处理过这个问题的,打包过后生成的bundle.js文件中处理热替换的代码都会被移除掉了,只剩下一个if(false) {}的空判断。这种没有意义的判断在代码压缩过后也会自动去掉,所以说不会影响生产环境中的运行状态。