手把手带你青铜上钻石之——webpack

1,921 阅读18分钟

本文是基础教程,意在让大家快速了解wepback主要功能,同时也是个人学习webpack时的笔记整理。

倔强青铜——初识webpack

初始化项目

创建项目文件夹,执行以下命令,生成package.json文件,初始化项目。

npm init

package.json文件如下,它包含项目的一些基本信息。

{
  "name": "aaa",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

安装webpack

//局部安装
npm install webpack webpack-cli -D 
//在webpack4中,由于webpack内核和webpack-cli分离,因此要同时安装webpack和webpack-cli。

-D 是 npm install --save-dev 的简写,表示安装模块并保存到pageage.json中的devDependencies属性,它表示项目开发所需要的模块。

-S 是 npm install --save 的简写,表示安装模块并保存到pageage.json中的dependencies属性,dependencies字段指定了项目运行所依赖的模块。

此时package.json中devDependencies属性如下:

"devDependencies": {    
    "webpack": "^5.4.0",    
    "webpack-cli": "^4.2.0"
}

根目录创建src文件夹及main.js文件。

命令行输入.\node_modules\.bin\webpack  .\src\main.js,提示编译成功,此时根目录下多出了dist文件夹及其子文件main.js,也就是我们打包出来的文件

需要注意的是:

在命令行打包时需要将“/”改成“\”,否则会提示“不是内部或外部命令,也不是可运行的程序 或批处理文件”。node_modules\.bin\webpack src\main.js

命令行中 / 后面的是命令所使用的参数, \ 是文件的所在路径。

可以看到在打包结果的后面跟着个warning信息,提示我们没有设置打包模式mode(规定当前是开发环境还是生产环境),这个warning在webpack2中是不会有的,但是在webpack4中,mode变成了一个重要概念(webpack会根据我们设置的mode默认为我们开启一些配置)。可以通过在命令后面跟上--mode development指定为开发环境或是--mode production指定为生产环境解决。

node_modules\.bin\webpack src\main.js --mode development

如果你实在是不想多敲两个字,可以使用npx webpack代替node_modules\.bin\webpack,npx是什么呢?

为什么我们没有指定打包输出的路径和文件名,webpack却可以打包输出呢?从webpack4开始支持零配置打包(固定的入口src/index.js和固定的出口dist/main.js),让我们看看它长啥样:

const path = require('path')
module.exports = {    
    entry: path.resolve(__dirname, 'src/index.js'),  //打包入口    
    output: {        
        filename: main.js,        
        path: path.resolve(__dirname, 'dist')    
    }
}

也就是说,webpack默认会以src下的index.js作为入口,如果存在,只需要执行.\node_modules\.bin\webpack就可以直接执行打包,不用添加文件及路径;如果不是src/index.js,就需要指定入口文件,比如我们的.\node_modules\.bin\webpack .\src\main.js;如果没有src/index.js文件并且在打包的时候没有指定入口文件,是会报错的。

context配置根目录

const path = require('path')
module.exports = {    
    context: __dirname,  //默认为启动webpack时所在的当前工作目录
    entry: path.resolve(__dirname, 'src/index.js'),  //打包入口    
}

如果将context设置成path.resolve(__dirname, 'src'),则寻找相对路径文件时,都是以相对于context的路径来描述。比如上面的entry可以写成entry: './index.js'。

entry配置

entry是打包的入口,它有多种写法:

1.字符串形式(对象形式缩写),一般用在单页应用:  entry: path.resolve(__dirname, 'src/main.js')
2.1对象形式,一般用在多页应用: entry: {    main: path.resolve(__dirname, 'src/main.js')}
2.2 entry: {    
    main: path.resolve(__dirname, 'src/main.js'),    
    search: path.resolve(__dirname, 'src/search.js')
}
3.1数组形式 entry,一般用在dll动态链接库: ['/src/main.js', './src/search.js']
3.2 entry: {    
    main: ['/src/main.js', './src/search.js']    
    search: ['/src/main2.js', './src/search2.js']
}
4.函数形式:函数返回上面三种任意一种

其中,字符串形式

entry: path.resolve(__dirname, 'src/main.js')

等同于

entry: {    
    main: path.resolve(__dirname, 'src/main.js')
}

默认是以main为键的对象形式。

数组形式最后的文件是按数组排列顺序来产出的。比如main: ['/src/a.js', './src/b.js'],a.js输出a,b.js输出b,那么最后输出的文件main.js输出a、b。一般用于单页面应用。有时用在js动态兼容上。

其中,entry代表打包的入口,webpack在打包的时候,会从入口指定的文件开始打包,而这些入口文件可能会通过import等形式又导入其它文件,而其它文件可能还导入了其它文件。。。这就形成了一个依赖图,它就像一棵树一样,把树根看作入口,那么对于树来说,它有很多的枝干,虽然多,但是从入口开始遍历,一定能找到所有枝干。找的这个过程,由webpack帮我们做了。

output配置

output表示打包的出口,可以发现在我们没有任何配置的时候进行打包,会在根目录创建dist文件夹,并输出main.js文件。

output有两个基本的属性,filename表示要输出时的(路径)文件名,path表示资源(所有)输出的公共目录。

我们可以通过-o命令指定打包的输出路径。

.\node_modules\.bin\webpack .\src\main.js  -o  .\disttemp

webpack5中新加入了--output-filename指定输出的文件名。

.\node_modules\.bin\webpack .\src\main.js --output-filename main2.js

在之前的版本中,可以通过-o同时指定输出的文件路径和文件名。

.\node_modules\.bin\webpack .\src\main.js  -o .\disttemp\main2.js

NPM script

每次打包都需要输入一串代码(.\node_modules\.bin\webpack .\src\main.js),那么有没有什么简短的方式呢?

npm run如果不加任何参数,直接运行,会列出package.json中所有可以执行的脚本命令。

npm内置了两个命令简写,npm test和npm start,它们是npm run xxx的简写形式。

npm run会创建一个shell,执行指定的命令,并**临时将node_modules/.bin加入环境变量PATH,**这就是我们能直接在npm script中访问模块的原因。

"scripts": {    
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "webpack ./src/main.js"
}

模块在安装的时候会在./node_modules/.bin下创建软链接,而npm script是可以直接访问到命令的。后面我们打包只需要npm run dev,就会执行打包命令

秩序白银——webpack配置文件

我们在根目录下创建webpack配置文件,默认文件名为webpack.config.js:

将我们刚刚推测的默认配置写上:

const path = require('path')
module.exports = {
    entry: path.resolve(__dirname, 'src/main.js'),
    output: {
        filename: main.js,
        path: path.resolve(__dirname, 'dist')
    }
}

你也可以这么写:

 entry: {
     main: path.resolve(__dirname, 'src/main.js')
 }

在我们没有配置文件时,我们执行webpack命令打包需要显式指定一个入口,当我们配置了webpack.config.js后,在package.json中的webpack ./src/main.js命令我们只需要保留webpack就可以了。webpack会自动读取该配置文件进行打包。

如果我给配置文件换个名字呢?

嗯?耗子喂汁

也不是不行。

我们在vue-cli或其他框架中会看到它们会对生产环境配置和开发环境进行区分。比如公共配置webpack.base.conf.js、开发环境配置webpack.dev.conf.js以及生产环境配置webpack.prod.conf.js。

如果使用自定义的配置文件名,我们在打包的时候就需要使用--config显示指定我们的配置文件和它所在的路径:

package.json

webpack --config ./build/webpack.dev.conf.js

entry为字符串或数组形式的情况下,output为一个文件名好理解,那么当我们需要打包的是多页面的时候该怎么配置呢?

src文件夹下新添加一个search.js,而后修改webpack.config.js文件如下:

const path = require('path')
module.exports = {
    entry: {
        main: path.resolve(__dirname, 'src/main.js'),
        search: path.resolve(__dirname, 'src/search.js')
    },
    output: {
        filename: '[name].js',
        path: path.resolve(__dirname, 'dist')
    }
}

output中的[name]表示占位符,什么意思呢,它所对应的就是入口entry对象的键名。我们执行打包命令,最后dist目录下会生成main.js和search.js文件。

如果多个入口打包到一个出口会怎么样?

module.exports = {
    entry: {
        main: path.resolve(__dirname, 'src/main.js'),
        search: path.resolve(__dirname, 'src/search.js')
    },
    output: {
        filename: 'test.js',
        path: path.resolve(__dirname, 'dist')
    }
}

多个入口打包到一个出口是不被允许的。

倒腾了这么久,也该看看打包后该怎么用了。

我们同时给main.js和search.js加入一些“标记”(打印几句话),然后进行打包。

打包完成后,在dist目录下创建index.html文件并引入打包好后的js文件,打开页面后F12(ctrl+shift+i)进入开发者工具,可以看到我们的控制台打印了刚刚的结果。

webpack之loader

webpack自身只能解析js文件,但是前端应用中的资源多的很,比如less文件或者图片资源,webpack是解析不了的,如何让webpack识别并解析它们,就要靠loader帮我们解决了。loader的执行是从右往左,从下往上的(可以通过enforce属性对执行顺序进行改变)。

从webpack2开始,默认支持导入json文件,所以在有的教程上会看到webpack能解析js文件和json文件这句话。但是当你使用自定义文件扩展名时,仍需要添加json-loader。

file-loader

file-loader用来解析文件。官网说的老清楚了。我们首先安装它:

npm install -D file-loader

我们在main.js中引入一张图片,我将它放在了src文件夹下。此时的项目结构如下:

(对没错,我的大儿子)

(对没错,还有个小儿子)

loader是通过module下的rules进行配置的,rule是一个数组,每一项都是一个对象,代表对某个资源的解析方式。每个对象有四个基本的属性:

  • test:匹配的文件。

  • use:应用的loader列表,可以是一个字符串,如use: 'file-loader'表示对匹配到的资源应用一个loader,可以是一个数组,表示使用多个loader。如use: ['file-loader', {

    loader: 'xxxx-loader',options: { ... }}]。其中loader表示要使用的单个loader,options表示loader的配置,每个loader都有自己的配置属性。

  • include:只匹配include所包含的文件。

  • exclude:不匹配exclude所包含的文件。

  • enforce:'post' | 'pre',代表执行顺序,没有配置则为普通loader,执行顺序为pre->普通loader->post。

我们修改main.js

console.log('This is main.js');
import imgSrc from './2.jpg'
var imgTag = new Image();
imgTag.src = imgSrc;
document.body.appendChild(imgTag);

webpack.config.js

module.exports = {
    entry: {
        main: path.resolve(__dirname, 'src/main.js'),
        search: path.resolve(__dirname, 'src/search.js')
    },
    output: {
        filename: '[name].js',
        path: path.resolve(__dirname, 'dist')
    },
    module: {
        rules: [
            {
                test: /\.(png|jpg|gif)$/,
                use: {
                    loader: 'file-loader',
                    //outputPath: 'img'  //图片输出的目录,默认为打包输出路径下,此处是进行拼接
                }
            }
        ]
    }
}

执行打包命令npm run dev,打开dist文件夹下index.html,可以看到图片已经被追加到页面上了。

url-loader——解析文件资源

url-loader和file-loader很像,区别就是url-loader有一个指定大小的属性limit,大于limit的会交给file-loader处理,小于loader的转为dataUrl,内敛在html文件中。这么做可以减少网咯请求,虽然增加了一些文件体积。

导入file:///C:/Users/86133/Desktop/webpackTest/dist/2.jpg会在输出目录生成文件 file.png并返回 public URL,即file:///C:/Users/86133/Desktop/webpackTest/dist/2.jpg。

需要注意的是,url-loader必须和file-loader配合起来用,什么意思呢,你可以不配file-loader,但是你需要安装,否则在资源大于limit时,会提示你安装file-loader。来吧,展示!

安装url-loader

npm install -D url-loader

接着进行配置,有了url-loader就不需要配置file-loader了:

rules: [
    {
        test: /\.(png|jpg|gif)$/,
        use: {
             loader: 'url-loader',
             options: {
                 limit: 170 * 1024,  //一般是使用8 * 1024,也就是8kb左右
                 //outputPath: 'img'  //图片输出的目录,默认为打包输出路径下,此处是进行拼接
             }
        }
    }
]

其中,limit的单位是byte。我的图片160k,我将它设为170,它最终应该转为dataUrl。

来打包瞄一眼,npm run dev

css-loader——解析css文件

css-loader是用来解析css文件的。

css会被解析成字符串:

\"*  {\\r\\n    background-color: red;\\r\\n}\"

首先进行安装:

npm install -D css-loader

webpack.config.js:

rules: [
    {
        test: /\.css$/,
        use: ['css-loader']
    }
]

然后在src文件夹下创建style.css,给body个背景色aqua,然后在main.js中引入。

import './style.css'

执行npm run dev进行打包。

打开index.html查看,发现样式没有生效。

为什么呢?

开发者工具审查元素看看网页结构,没有style标签,没有link标签,打开main.js看看。我们可以找到这么一段代码。

___CSS_LOADER_EXPORT___.push([module.i, "body {\n    background-color: rgb(41, 85, 85);\n    display: -webkit-box;\n    display: -webkit-flex;\n    display: -ms-flexbox;\n    display: flex;\n    -webkit-box-pack: center;\n    -webkit-justify-content: center;\n        -ms-flex-pack: center;\n            justify-content: center;\n    -webkit-box-align: center;\n    -webkit-align-items: center;\n        -ms-flex-align: center;\n            align-items: center;\n}\", \"\"])"

css文件解析的结果被css-loader转换为一个js模块,push到一个数组里,这个数组是由 css-loader 内部的一个模块提供的,但是没有使用到这个数组。

我们说过,**loader只负责解析文件,**为了让css生效,我们需要将css-loader解析出来的结果通过style-loader处理,即通过style标签插入到head标签中,默认每个css文件都会生成一个style标签(可通过参数配置)。

style-loader——将解析的css挂载到style标签

npm install -D style-loader

更改webpack.config.js

{
    test: /\.css$/,
    use: ['style-loader', 'css-loader']            
}

loader的作用规则是从右往左依次进行的,我们需要先解析css文件然后再通过style标签挂载到页面。

打包后打开index.html可以发现页面背景色发生了改变。

sass-loader——解析sass/scss文件

sass-loader负责解析scss文件。

npm install -D sass-loader

webpack.config.js:

{
     test: /\.scss$/,
     use: ['style-loader', 'css-loader', 'sass-loader']
}

然后在src文件夹下创建style2.scss,给img个border,然后在main.js中引入。

import './style2.scss'

打包后打开网页查看效果。

需要注意的是sass-loader是依赖sass的,所以sass也需要进行安装。

postcss-loader——自动添加浏览器前缀

前端的部分css属性由于产商的不同需要进行兼容,而postcss结合autoprefixer能帮助我们自动添加浏览器前缀(注意autoprefixer需要指定添加前缀的范围)。

npm install -D postcss-loader autoprefixer

配置方式1:在根目录下添加配置文件postcss.config.js:

module.exports = {
    plugins: [
        require('autoprefixer')({
            overrideBrowserslist: [
                "Android 4.1",
                "iOS 7.1",
                "Chrome > 31",
                "ff > 31",
                "ie >= 8"
            ]
        })
    ]
};

webpack.config.js:

 {
      test: /\.css$/,
      use: ['style-loader', 'css-loader', 'postcss-loader']
}

配置方式2:webpack.config.js配置文件中配置:

{
     test: /\.css$/,
    use: ['style-loader', 'css-loader', {
        loader: 'postcss-loader',
        options: {
            plugins: [
                require('autoprefixer')({
                    overrideBrowserslist: [
                        "Android 4.1",
                        "iOS 7.1",
                        "Chrome > 31",
                        "ff > 31",
                         "ie >= 8"
                     ]
                })
             ]
         }
    }]
}

注意:在webpack5中options下plugin属性已经没了,需要在options下的plugins外再包层postcssOptions(options->postcssOptions->plugins:[])。

配置方式3:package.json中设置browserslist字段:

"browserslist": [
    "last 1 version",
    "> 1%",
    "IE 10"
]

webpack.base.config.js

{
    test: /\.css$/,
    use: [MiniCssExtractPlugin.loader, 'css-loader', {
        loader: "postcss-loader",
        options: {
            postcssOptions: {
                plugins: [
                    [
                        "autoprefixer"
                    ]
                ]
            }
        }
    }]
}

配置方式4:配置方式3的webpack.base.config.js+根目录创建.browserslistrc文件:

last 2 version
> 1%

babel-loader——处理js语法

npm install -D babel-loader @babel/core @babel/preset-env

为什么安装个babel-loader还要安装@babel/core @babel/preset-env

babel 的 node API 已经被移到 babel-core 中,如果不安装@babel/core,webpack会提示你先进行安装。

rules: [
    {
        test: /\.js$/,
        exclude: /(node_modules)/,
        use: {
            loader: 'babel-loader',
            options: {
                presets: ['@babel/preset-env']  //这是babel-loader的写法
            }
        }
    }
]

实际上除了在babel-loader的options选项中使用presets,还可以在项目根目录下创建.babelrc文件,它使用json格式。

.babelrc:

{  "presets": [["@babel/preset-env"]]  }  

webpack之plugins

plugin就是插件,但凡loader做不到的事,你可以找plugins。

html-webpack-plugin——自动创建index.html并引入文件

我们在看打包效果的时候,是在dist目录下创建index.html并手动引入打包的js文件和css文件,如果文件一多岂不凉凉——html-webpack-plugin帮助我们自动生成一个html文件并引入打包好的文件(如果你有多个 webpack 入口点, 他们都会在生成的HTML文件中的 script 标签内。如果有任何CSS assets 在webpack的输出中,例如, 利用ExtractTextPlugin提取CSS, 那么这些将被包含在HTML head中的<link>标签内)。

npm install -D html-webpack-plugin

webpack.config.js:

var HtmlWebpackPlugin = require('html-webpack-plugin');
modules: {
    ...
},
plugins: [
    new HtmlWebpackPlugin({
        template: path.resolve(__dirname, 'src/index.html')
    })
]

其中,template指定一个项目中的html文件作为模板,不指定的话它也会在dist文件夹下自动生成一个html文件,那它有啥用呢,举个例子,比如你想用自己的网页title,而不是自动生成的网页的默认title,那么你就项目中创建个html文件,配置好你想配置的,让它作为模板。

删除dist目录下index.html后打包,发现dist目录下多出了index.html文件。

clean-webpack-plugin——清理构建输出目录

clean-webpack-plugin一般用在生产环境(因为开发环境使用的是devServer,一般不打包的,也就不存在构建目录了)。

如果我们修改了entry的键名,打包后输出的文件名也会变,那么dist目录会越来越多没有用的文件,因此我们引入clean-webpack-plugin帮助我们每次打包前自动清除打包目录。

npm install -D clean-webpack-plugin

webpack.base.conf.js

const {CleanWebpackPlugin} = require('clean-webpack-plugin')
plugins: [
    new CleanWebpackPlugin()
]

这样每次打包后我们都不需要自己删除没有用的文件了。

mini-css-extract-plugin——将style提取成单独的css文件

mini-css-extract-plugin一般用在生产环境。

npm i -D mini-css-extract-plugin

webpack.config.js:

const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module: {
        rules: [
            ...
            {
                test: /\.css$/,
                use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader']
            },
            {
                test: /\.scss$/,
                use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader']
            }
        ]
},
plugins: [
        ...
        new MiniCssExtractPlugin({            filename: '[name].css'  //默认值是以chunk为文件名        })]

需要将'style-loader'替换成MiniCssExtractPlugin.loader,否则不生效。

打包后查看index.html,可以看到css通过link标签引入了。

mini-css-extract-plugin一般用在生产环境,开发环境一般使用style-loader,打包速度会更快(少引插件少做额外处理,而且开发环境也没有必要抽取成css文件)。但是如果实在想在开发使用该插件,建议这么使用(不知道contenthash是什么的建议学到文件指纹后再回来看这部分的内容):

new MiniCssExtractPlugin({    filename: devMode? '[name].css' : '[name]_[contenthash].css'})

开发环境下使用固定的css文件名。如果使用的是contenthash作为文件名后缀,那么修改css后需要手动刷新才能得到修改的结果,这是因为热更新的缘故,在修改css后会重新构建,此时生成的css文件的contenthash会发生变化,而html-webpack-plugin自动生成的html文件对css的link引用还是之前那个名字。

修改前的文件名:

修改后的文件名:

刷新后的文件名:

terser-webpack-plugin——压缩js,多进程并行压缩(加快打包构建速度)

一般用在生产环境

npm install -D terser-webpack-plugin 

webpack.prod.conf.js

const TerserPlugin = require('terser-webpack-plugin');
module.exports = merge(webpackBaseConfig, {
    mode: 'production',
    optimization: {
        minimize: true,
        minimizer: [
            new TerserPlugin({
                parallel: true  //开启多进程打包
            })
        ]
    }
    plugins: [
        ..
    ]
})

parallel:Type: Boolean|Number Default: true

可以为它设置boolean或number值,为true时默认为CPU核心数-1,也可以设置具体数值。

optimize-css-assets-webpack-plugin——压缩css

一般用在生产环境。

npm install -D optimize-css-assets-webpack-plugin

webpack.prod.conf.js

var OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
module.exports = merge(webpackBaseConfig, {
    mode: 'production',
    plugins: [
        ...
        new OptimizeCssAssetsPlugin({
            assetNameRegExp: /\.css$/g,  //匹配的文件,默认为css文件
            cssProcessor: require('cssnano')  //默认的css处理器,可配置别的
        })
    ]
})

压缩后的css文件大概长这样:

copy-webpack-plugin——拷贝资源

npm install -D copy-webpack-plugin

在src目录下新建image文件夹,放入一些图片,然后进行路径配置。

const CopyWebpackPlugin = require('copy-webpack-plugin');
module.exports = {
    ...
    plugins: [
       ...
       new CopyWebpackPlugin({
            patterns: [
                {
                  from: path.resolve(__dirname, './src/image'),  //需要拷贝的资源路径 
                  to: path.resolve(__dirname, 'dist/static')  //拷贝到哪儿
                }
            ]
        })
    ]
}

需要注意的是,现在的CopyWebpackPlugin需要传入配置对象的patterns属性来放置拷贝规则数组,之前的CopyWebpackPlugin是直接传入拷贝规则数组的。

荣耀黄金——webpack之devServer

在学习devServer之前,我们先插播一下文件监听:

文件监听

文件监听是在发现源码发生变化的时候,自动重新构建出输出文件(唯一缺陷是需要用户手动刷新浏览器)。

文件监听原理:

module.exports = {
    watch: true,
    watchOptions: {  //只有在watch: true开启监听模式时才生效
        //默认为空,不监听的文件或文件夹,支持正则匹配 
        ignored: /node_modules/,
        //监听到变化发生后会等待300ms再执行,默认为300ms
        aggregateTimeout: 300,
        //每秒询问文件系统的次数,默认每秒1000次
        poll: 1000
    }
};

需要注意的是,我们需要先关闭CleanWebpackPlugin,否则监听到文件变化后会自动删除index.html文件。

devServer

devServer能开启一个简单的web服务器。简称WDS。devServer底层也是通过--watch对文件进行监听的。

npm install -D webpack-dev-server

webpack.config.js:

module.exports = {
    ...
    devServer: {
        contentBase: path.join(__dirname, "dist"),  //将dist目录下的文件,作为可访问文件。
        compress: true,  //是否启用gzip压缩
        port: 9000,  //在该端口下打开服务
        open: true,  //是否自动打开浏览器
        //hot: true  //设置为true才能使热更新生效
    }, 
    plugins: []
}

其中compress表示开启gzip压缩,port表示开启服务的端口,open表示服务启动时自动打开浏览器。

接下来我们要使用WDS的专属命令了:

package.json

"dev": "webpack-dev-server"

如果是自定义的文件名,使用--config 文件路径文件名。

devServer的open参数可以在NPM script中使用--open代替。

"dev": "webpack-dev-server --config ./webpack.config.js --open"

open参数实际上除了Boolean值外还可以是String,用来指定开启的浏览器,设置为true则开启默认浏览器,可以指定为"Chrome"等。

"dev": "webpack-dev-server --config ./webpack.config.js --open Chrome"

注:使用webpack-dev-server打包后,是不会在项目文件中生成dist目录的,它会将文件打包到内存中。

模块热替换插件HotModuleReplacementPlugin

先前我们在修改项目文件时,如果修改了文件,需要重新打包,才能看到最新的效果。而模块热替换是在程序运行时,对修改部分的文件进行操作的,当你修改了一个地方,页面不会重新渲染。

HotModuleReplacementPlugin通常搭配derServer一起使用,它是webpack自带的

webpack.config.js:

var webpack = require('webpack')
module.exports = {
    devServer: {
        ...
        hot: true  //devServer默认是以刷新网页来更新的,设置为true才能使热更新生效
    },
    plugins: [
        new webpack.HotModuleReplacementPlugin()
    ]
}

除了在webpack.config.js中配置hot:true之外,还可以在NPM script中使用--hot参数来开启热更新。

webpack5中配置devServer

"webpack": "^5.6.0",

"webpack-cli": "^4.2.0",

"webpack-dev-server": "^3.11.0"

在webpack-cli4中,cli文件结构发生了变化,从webpack-cli3迁移过来时,会出现

Error: Cannot find module 'webpack-cli/bin/config-yargs'的提示,是因为在webpack-cli4中,devServer的启动命令由webpack-dev-server变成webpack serve

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "webpack serve"
}

然后就又可以开开心心装逼辣。

如果我不使用HMR插件,仅开启devServer{ hot:true }呢?

我就知道有人跟我一样好奇这个问题。

在安装了webpack-dev-server之后,我们可以在依赖包中看到这么一段代码:

node_modules/webpack-dev-server/utils/addEntries.js

if (options.hot || options.hotOnly) {
        config.plugins = config.plugins || [];
        if (
          !config.plugins.find(
            // Check for the name rather than the constructor reference in case
            // there are multiple copies of webpack installed
            (plugin) => plugin.constructor.name === 'HotModuleReplacementPlugin'
          )
        ) {
          config.plugins.push(new webpack.HotModuleReplacementPlugin());
        }
}

什么意思呢,如果你配置了hot参数或hotOnly参数为true,那么webpack-dev-server就会检查你是否添加了HMR插件,如果没有添加的话,会自动帮你添加。嗯,果然还是逃不掉的吗。

尊贵铂金——webpack之webpack-merge

我们日常开发中使用的webpack配置以及打包上线的webpack配置有一定区分。

在webpack.config.js中,有个mode属性,它用来表示当前配置所应用的环境,默认是production生产环境。

webpack.config.js:

module.exports = {
    mode: 'production',  //不写等同于production
    entry: {
        ...
    }
}

它们有如下区别(截自webpack官网):

生产环境中会启用ModuleConcatenationPlugin插件。每个模块都会被webpack包装在单独的函数闭包中,这些包装函数使JavaScript在浏览器中的执行速度变慢。而ModuleConcatenationPlugin会将所有模块按引用顺序放在一个函数闭包中。可以通过new webpack.optimize.ModuleConcatenationPlugin()手动添加。

开发环境中会自动开启NamedModulesPlugins,webpack为了减小文件大小(所以不要在生产模式使用啊喂!),会对模块进行重命名,当程序报错的时候,是看不出来当前的错误来自哪个文件的。

开启前的报错:

开启后报错:

开启前的更新信息:

开启后的更新信息:

妙不妙?

生产模式会开启FlagDependencyUsagePlugin,会删除无用的代码,比如导入了某个模块之后未进行使用,那么这个模块是不会被打包的。

那么开发环境和生产环境有一些公共的配置,如果仅靠cv大法,那属实有点丑,也不好维护。怎么办呢?

webpack-merge

npm install -D webpack-merge

我们将配置文件重命名为webpack.base.conf.js作为我们的公共配置文件,同时创建一个生产环境使用的配置文件webpack.prod.conf.js以及开发环境下使用的配置文件webpack.dev.conf.js。

同时对配置做一些调整:

devServer官方建议不要放在生产环境,因此抽离devServer以及HotModuleReplacementPlugin这两兄弟到webpack.dev.conf.js中

webpack.base.conf.js:

const path = require('path');
var HtmlWebpackPlugin = require('html-webpack-plugin');
var {CleanWebpackPlugin} = require('clean-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
    entry: {
        main: path.resolve(__dirname, 'src/main.js'),
        search: path.resolve(__dirname, 'src/search.js')
    },
    output: {
        filename: '[name].js',
        path: path.resolve(__dirname, 'dist')
    },
    module: {
        rules: [
            {
                test: /\.css$/,
                use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader']
            },
            {
                test: /\.scss$/,
                use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader']
            },
            {
                test: /\.(png|jpg|gif)$/,
                use: {
                    loader: 'url-loader',
                    options: {
                        limit: 170 * 1024
                    }
                }
            }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: path.resolve(__dirname, 'src/index.html')
        }),
        new CleanWebpackPlugin(),
        new MiniCssExtractPlugin({
            filename:'[name].css'
        })
    ]
}

webpack.dev.conf.js:

const path = require('path');
const {merge} = require('webpack-merge')
var webpack = require('webpack')
const webpackBaseConfig = require('./webpack.base.conf.js')
module.exports = merge(webpackBaseConfig, {
    mode: 'development',
    devServer: {
        contentBase: path.join(__dirname, "dist"),
        compress: true,
        port: 9000,
        open: true,
        hot: true
    },
    plugins: [
        new webpack.HotModuleReplacementPlugin()
    ]
})

webpack.dev.conf.js:

const {merge} = require('webpack-merge')
const webpackBaseConfig = require('./webpack.base.conf.js')
module.exports = merge(webpackBaseConfig, {
    mode: 'production'
})

此时的项目目录结构如下:

由于我们的配置文件名改了,所以我们要修改package.json中的命令并增加生产模式的打包命令prod。

 "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "webpack-dev-server --config ./webpack.dev.conf.js",
    "dev2": "webpack --config ./webpack.dev.conf.js",  //开发环境打包
    "prod": "webpack --config ./webpack.prod.conf.js"
  }

分别测试,功能正常。

特殊情况

有时候我们可能想在公共配置文件中进行”不同环境采取不同loader/plugin“的操作。通过刚刚的了解,我们知道当给mode参数设置为开发环境development(production)时,webpack会将process.env.NODE_ENV的值设为development(production),但是当我们在webpack配置文件中打印process.env.NODE_ENV会发现值为undefined,而不是所谓的development或production,哦!它设了个空气!

我们尝试在src/main.js中打印process.env.NODE_ENV,分别执行开发环境npm run dev2和生产环境npm run prod,打开控制台发现就是我们想要的值。

那怎么在配置文件中判断当前的环境呢?

在搜索引擎里连滚带爬了一阵子后,找了个“偏方”。

webpack.base.conf.js

const devMode = process.argv.indexOf('--mode=production') === -1;

需要配合NPM script使用:

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "webpack-dev-server --config ./webpack.dev.conf.js",
    "dev2": "webpack --mode=development --config ./webpack.dev.conf.js",
    "prod": "webpack --mode=production --config ./webpack.prod.conf.js"
}

--mode用来指定当前环境。

如果配置文件中的mode与NPM script的--mode同时存在,是以--mode优先的。

然后就可以这么用了:

{
      test: /\.css$/,
      use: [ devMode? 'style-loader' : MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader']
}

文件指纹策略

在学习文件指纹前,让我尝试用一张图勾起你们的回忆:

什么?没回忆起来?

我帮你回忆回忆:

文件指纹是打包输出的文件名后缀(注意后缀和扩展名的区别)

有什么用呢?

举个栗子:你们公司有个打包上线了段时间的项目,有天你对某个文件如index.js进行了改动,重新发布。此时你的更新对于用户是无感知的,在用户客户端本地,先前已经对index.js资源进行了缓存,如果此时客户端的缓存还处于可用状态,那么就会直接从缓存中读取index.js文件,而你的更新对于这部分用户来说是察觉不到的,所以由于缓存的存在,当你需要获取新的代码时,就会出现不符预期的行为。(简而言之就是部署新版本时如果不更改资源的文件名,浏览器可能会认为它没有被更新,就会使用它的缓存版本)

这显然不符合我们的预期:对于已更新的内容,我们期望客户端发起资源的请求,对于没有更新的内容,我们期望客户端能直接从浏览器缓存中读取。

对没错,就是它!

先来看看常见的文件指纹有哪几种:

  • Hash:和整个项目文件的构建有关,只要项目文件有修改,整个项目构建的hash值就会改变。
  • Chunkhash:和webpack打包的chunk有关,不同的entry会生成不同的chunkhash值。
  • Contenthash:根据文件内容生成的hash,文件内容不变,则contenthash不变。

文件指纹怎么用呢?

webpack.base.conf.js:

module.exports = {
    entry: {
        main: path.resolve(__dirname, 'src/main.js'),
        search: path.resolve(__dirname, 'src/search.js')
    },
    output: {
        filename: devMode? '[name].js' : '[name]_[hash].js',  //生产环境下使用文件指纹
        path: path.resolve(__dirname, 'dist')
    }
}

search.js

console.log('This is search page')

main.js

import './style.css'console.log('This is main.js');

此时我们执行npm run prod,生成的部分内容如下:

我们发现文件名都加上了该次构建相关的hash。此时解决了“更新文件后,用户浏览器依然读取缓存文件”的问题。因为每次修改文件重新打包都会改变hash值,所以文件名也会进行相应的改变。

如果我仅仅修改main.js文件呢?

import './style.css'console.log('This is main.js2');

执行打包npm run prod

输出的js文件的文件名(hash)全部发生了改变。换句话说就是重新发布后,用户需要重新请求所有资源。

要不。。。

跑路是不可能跑路的(主要是跑不过),那就只能改了!

这不是还有chunkhash吗

module.exports = {
    entry: {
        ...
    },
    output: {
        filename: devMode? '[name].js' : '[name]_[chunkhash].js',
        path: path.resolve(__dirname, 'dist')
    }
}

webpack,启动!

可以看到每个bundle都生成了各自的chunkhash,互不影响。此时继续修改main.js(main.js:别扶我,我还能改!)。

webpack,启动!

可以看到,修改了main.js后重新打包,search的chunkhash没有发生变化。对于用户浏览器来说,既能读取到最新的main.js,又能读取缓存中的search.js,美滋滋。

等等!还有个Contenthash呢?

电脑左上角手机右下角点赞可解锁新内容——Contenthash

我们看看上面几张图,当然不是表情包了啊喂!抽出它的共同点,发现了啥?

对没错,是main.css

如果我是修改css文件然后打包发布,是不是也会出现浏览器直接读取缓存文件的情况?

那么css文件需要使用的文件指纹是hash?chunkhash?

结合刚才所讲,hash肯定是不用考虑了(不缓存),chunkhash如何?

module.exports = {
    plugins: [
        new MiniCssExtractPlugin({
            filename: devMode? '[name].css' : '[name]_[chunkhash].css'
        })
        ...
    ]
}

如果是这样的话,那么修改main.js,main.css的文件名也会发生变化。这显然也不合适。我们试试contenthash:

module.exports = {
    plugins: [
        new MiniCssExtractPlugin({
            filename: devMode? '[name].css' : '[name]_[contenthash].css'
        })
        ...
    ]
}

webpack,启动!

修改main.js后再打包查看:

可以发现css文件依然是原本的文件名。但是我们修改css文件后查看,发现main.js和main.css文件名都改变了,这是因为main这个bundle的chunkhash因css文件的修改而改变了。

hash一般用在文件上:

{
        test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: utils.assetsPath('img/[name].[hash:7].[ext]')
        }
}

总结一下:

js的文件指纹设置:设置output的filename,使用chunkhash。

css的文件指纹设置:设置MiniCssExtractPlugin的filename,使用contenthash。

图片等资源的文件指纹设置:设置file-loader的name,使用hash。

不要在开发环境使用 [chunkhash]/[hash]/[contenthash],因为不需要在开发环境做持久缓存,而且这样会增加编译时间,开发环境用 [name] 就可以了。

卸载游戏——搭建vue开发环境

安装vue-loader和vue-template-compiler

npm install -D vue-loader vue-template-compiler

其中vue-template-compiler用来编译template模板。

配置vue-loader

webpack.base.conf.js:

const vueLoaderPlugin = require('vue-loader/lib/plugin')
module: {
    rules: [
        {
              test: /\.vue$/,
              use: [
                  {
                       loader: 'vue-loader'
                  }
              ] 
        }
    ]
},
plugins: [
    ...
    new vueLoaderPlugin()
]

main.js

import Vue from 'vue';
import App from './app.vue';
new Vue({
    render: h=>h(App)
}).$mount('#app')

在src文件夹下新建app.vue文件

<template>
  <div>
      {{title}}
  </div>
</template>
<script>
export default {
    data() {
        return {
            title: "Hello Vue!"
        }
    }}
</script>

index.html文件

<!DOCTYPE html><html lang="en">
<head>
    ...
    <title>my first webpack</title>
</head>
<body>
    <div id="app"></div>
</body>
</html>

webpack,启动~!(在这之前要安装vue的啊,别忘了)

快乐的很。

真香——加餐篇

大家都知道的webpack优化策略