一看就懂之webpack高级配置与优化

一看就懂之webpack基础配置
一看就懂之webpack原理解析与实现一个简单的webpack

一、打包多页面应用

所谓打包多页面,就是同时打包出多个html页面,打包多页面也是使用html-webpack-plugin,只不过,在引入插件的时候是创建多个插件对象,因为一个html-webpack-plugin插件对象只能打包出一个html页面。如:

module.exports = {
    entry: {
        index: "./src/index.js", // 指定打包输出的chunk名为index
        foo: "./src/foo.js" // 指定打包输出的chunk名为foo
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: "./src/index.html", // 要打包输出哪个文件,可以使用相对路径
            filename: "index.html", // 打包输出后该html文件的名称
            chunks: ["index"] // 数组元素为chunk名称,即entry属性值为对象的时候指定的名称,index页面只引入index.js
        }),
        new HtmlWebpackPlugin({
            template: "./src/index.html", // 要打包输出哪个文件,可以使用相对路径
            filename: "foo.html", // 打包输出后该html文件的名称
            chunks: ["foo"] // 数组元素为chunk名称,即entry属性值为对象的时候指定的名称,foo页面只引入foo.js
        }),
    ]
}
打包多页面时,关键在于chunks属性的配置,因为在没有配置chunks属性的情况下,打包输出的index.html和foo.html都会同时引入index.js和foo.js,所以必须配置chunks属性,来指定打包输出后的html文件中要引入的输出模块,数组的元素为entry属性值为对象的时候指定的chunk名,如上配置,才能实现,index.html只引入index.js,foo.html只引入foo.js文件

js文件可以通过chunks属性进行筛选,但是css则无法筛选,css是否会被html文件所引入,完全是看html中引入的js文件,如果引入的js中require或者import了某个css文件,那么这个css文件就会被引入到该html文件中。

二、配置source-map

source-map就是源码映射,主要是为了方便代码调试,因为我们打包上线后的代码会被压缩等处理,导致所有代码都被压缩成了一行,如果代码中出现错误,那么浏览器只会提示出错位置在第一行,这样我们无法真正知道出错地方在源码中的具体位置。webpack提供了一个devtool属性来配置源码映射。

let foo = 1;
console.lg(`console对象的方法名log写成了lg`); // 源文件第二行出错
index.js:1 Uncaught TypeError: console.lg is not a function
    at Object.<anonymous> (index.js:1)
    at o (index.js:1)
    at Object.<anonymous> (index.js:1)
    at o (index.js:1)
    at index.js:1
    at index.js:1
源码中出错的位置明明是第二行代码,而浏览器中提示的错误确实在第一行,所以如果代码很复杂的情况下,我们就无法找到出错的具体位置

devtool常见的有4种配置:
① source-map: 这种模式的特点是会产生一个.map文件,文件里面保留了打包后的文件与原始文件之间的映射关系,能够通过map文件逆向解析出源码内容,所以出错了能够提示到具体的行和列,打包输出文件中会指向生成的.map文件,告诉js引擎源码在哪里,由于源码与.map文件分离,所以需要浏览器发送请求去获取.map文件,常用于生产环境,如:

// 打包后输出的bundle文件末尾会包含以下内容
//# sourceMappingURL=index.js.map

source-map解析到的是真正的源码,也就是说是loader处理前的源码,所以source-map是最慢的同时提示信息也是最全的

② eval: 这种模式打包速度最快,但是不会生成.map文件,会使用eval将模块包裹,在末尾加入sourceURL,常用于开发环境,如:

//# sourceURL=webpack:///./src/index.js

报错的时候只能定位到具体是哪个文件路径无法知道其在源码中的行列信息

③ eval-source-map: 每个 module 会通过 eval() 来执行,并且生成一个 DataUrl 形式的 SourceMap(即base64编码形式内嵌到eval语句末尾), 但是不会生成.map文件,可以减少网络请求,浏览器中点击报错文件链接,光标可以定位到源码中出错位置的行和列,但是打包文件会非常大

//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly8vLi9zcmMvaW5kZXguanM/YjYzNSJdLCJuYW1lcyI6WyJmb28iLCJjb25zb2xlIiwibGciXSwibWFwcGluZ3MiOiJBQUFBLElBQUlBLEdBQUcsR0FBRyxDQUFWO0FBQ0FDLE9BQU8sQ0FBQ0MsRUFBUix1RSxDQUFxQyIsImZpbGUiOiIuL3NyYy9pbmRleC5qcy5qcyIsInNvdXJjZXNDb250ZW50IjpbImxldCBmb28gPSAxO1xuY29uc29sZS5sZyhgY29uc29sZeWvueixoeeahOaWueazleWQjWxvZ+WGmeaIkOS6hmxnYCk7IC8vIOa6kOaWh+S7tuesrOS6jOihjOWHuumUmVxuIl0sInNvdXJjZVJvb3QiOiIifQ==\n//# sourceURL=webpack-internal:///./src/index.js

本质上说,eval-source-map和source-map其实是一样的,只不过是将map文件通过base64编码的形式内置到了打包输出文件中。所以其报错信息也是全(包含行和列)并且准确(loader转换前)的。

④ cheap-source-map: 加上 cheap,相对于source-map而言,就只会提示到第几行报错,少了列信息提示,可以提高打包性能,但是仍然会产生.map文件,并且其解析到的是经过loader转换后的源码

⑤ cheap-eval-source-map: 加上 cheap,就只会提示到第几行报错,相对于eval-source-map,少了列信息提示,并且其解析到的是经过loader转换后的源码

所以加上cheap之后,报错信息就变得不准确了,少了列信息,解析到的源码是经过loader处理之后的

⑥ cheap-module-source-map: 和cheap-source-map相比,加上了module,报错的时候定位到的就是loader转换前的源码位置,并且也会产生.map文件,用于生产环境

⑦ cheap-module-eval-source-map: 常用于开发环境,使用 cheap 模式可以大幅提高 souremap 生成的效率,加上module可以定位到源码经过loader转换前的位置,eval提高打包构建速度,并且不会产生.map文件减少网络请求。

凡是带eval的模式都不能用于生产环境,因为纯eval只能定位报错文件路径,无法知道具体信息;eval-source-map其不会产生.map文件,会导致打包后的文件变得非常大。通常我们并不关心列信息,所以都会使用cheap模式,但是我们需要查看loader转换前的源码,以便精准找到错误的位置,所以使用cheap的时候要加上module。开发环境通常用cheap-module-eval-source-map,生产环境通常用cheap-module-source-map

三、三个常见小插件的使用

clean-webpack-plugin: 其作用就是每次打包前先先将输出目录中的内容进行清空,然后再将打包输出的文件输出到输出目录中。

const {CleanWebpackPlugin} = require("clean-webpack-plugin");
module.exports = {
    plugins: [
        new CleanWebpackPlugin() // 打包前清空输出目录
    ]
}
需要注意的是,require("clean-webpack-plugin)的结果是一个对象而不是类,这个对象中的CleanWebpackPlugin属性才是一个类,我们就是用这个类去创建插件对象

copy-webpack-plugin: 其作用就是打包的时候带上一些README.md、history.md等等一起输出到输出目录中

module.exports = {
    plugins: [
        new CopyWebpackPlugin({
            "patterns": [
                {
                    from: "./README.md", // 将项目根目录下的README.md文件一起拷贝到输出目录中
                    to: "", // 属性值为空字符串则表示是输出目录
                    //to: path.resolve(__dirname, "dist/copy"), // 可以是绝对路径
                    //to: "./dist/copy" // 也可以是相对路径
                }
            ]
        })
    ]
}
to可以配置绝对路径也可以配置相对路径。

BannerPlugin: 其作用就是在打包输出的js或者css等文件的头部添加一些文字注释,比如版权说明等等,BannerPlugin是webpack内置的插件,如:

module.exports = {
    plugins: [
        new webpack.BannerPlugin("Copyright © 2019") // 在js文件头部添加版权说明
    ]
}

四、webpack跨域问题

为什么webpack会存在跨域问题?因为webpack打包的是前端代码,其最终会被部署到前端服务器上,而前后端代码通常部署在不同的服务器上,即使是部署在同一个服务器上,所使用的端口也是不一样的,当前端代码通过ajax等手段向后端服务器获取数据的时候,由于前后端代码不在同一个域中,故存在跨域问题。比如,我们通过webpack的devServer来运行部署我们的前端应用代码,devServer启动在8080端口上,而前端应用代码中会通过ajax请求后端数据,后端服务器启动在3000端口上。
// index.js

const xhr = new XMLHttpRequest();
// xhr.open("get", "http://localhost:3000/api/test"); // 由于跨域问题无法直接访问到http://localhost:3000下的资源
xhr.open("get", "/api/test"); // 本来是要访问http://localhost:3000/api/test
xhr.onreadystatechange = () => {
    if (xhr.readyState === 4) {
        console.log(xhr.responseText);
    }
}
xhr.send();
由于前端代码是运行在浏览器中的,如果在前端代码中直接通过ajax向http://localhost:3000/api/test发起请求获取数据,那么由于浏览器同源策略的影响,会存在跨域的问题,所以必须访问/api/test,但是这样访问又会出现404问题,因为其实访问的是http://localhost:8080/api/test,8080服务器上是没有该资源的,解决办法就是通过devServer配置一个代理服务器
module.exports = {
    devServer: {
        proxy: {
            "/api": "http://localhost:3000" // 路径以/api开头则代理到localhost:3000上
        }
    }
}
访问http://localhost:8080/api/test就会被代理到http://localhost:3000/api/test上,proxy还支持路径的重写,如果3000端口服务器上并没有/api/test路径,只有/test路径,那么就可以对路径进行重写,将/api替换掉
module.exports = {
    devServer: {
        proxy: {
            "/api": {
                target: "http://localhost:3000",
                pathRewrite: {"/api": ""} // 将/api替换掉
            }
        }
    }
}
访问http://localhost:8080/api/test就会被代理到http://localhost:3000/test上

如果前端只是想mock一些数据,并不需要真正的去访问后台服务器,那么我们可以通过devServer提供的before钩子函数获取到内置的服务器对象进行处理请求,这个内置的服务器对象就是webpack的devServer即8080端口的server,因为是在同一个服务器中请求数据所以也不会出现跨域问题。

before(app) { // 此app即webpack的devServer
            app.get("/api/test", (req, res, next) => {
                res.json({name: "even"});
            })
        }

我们还可以不通过webpack提供的devServer来启动webpack,而是使用自己服务器来启动webapck
// server.js

const express = require("express");
const app = express();
const webpack = require("webpack"); // 引入webpack
const config = require("./webpack.config.js"); // 引入配置文件
const compiler = webpack(config); // 创建webpack的编译器
const middleWare = require("webpack-dev-middleware"); //引入webpack的中间件
app.use(middleWare(compiler)); // 将compiler编译器交给中间件处理
app.get("/api/test", (req, res, next) => {
    res.json({name: "lhb"});
});
app.listen(3000);
通过自定义服务器启动webpack,这样webpack中的前端代码请求数据就和服务器的资源在同一个域中了。

五、resolve配置

resolve用于配置模块的解析相关参数的,其属性值为一个对象
modules: 告诉webpack 解析模块时应该搜索的目录,即require或import模块的时候,只写模块名的时候,到哪里去找,其属性值为数组,因为可配置多个模块搜索路径,其搜索路径必须为绝对路径,比如,src目录下面有一个foo.js文件和index.js文件:
// index.js

const foo = require("./foo"); // 必须写全foo.js模块的路径
// const foo = require("foo"); // resolve.modules中配置了模块解析路径为.src目录,则可用只写foo即可搜索到foo.js模块
console.log(foo);
module.exports = {
    resolve: {
        modules: [path.resolve(__dirname, "./src/"), "node_modules"]
    },
}
由于resolve.modules中配置了./src目录作为模块的搜索目录,所以index.js中可以只写模块名即可搜索到foo.js模块

alias: 用于给路径或者文件取别名,当import或者require的模块的路径非常长时,我们可以给该模块的路径或者整个路径名+文件名都设置成一个别名,然后直接引入别名即可找到该模块,比如,有一个模块位置非常深

// const foo = require("./a/b/c/foo"); // foo.js在./src/a/b/c/foo.js
// const foo = require("foo"); // foo被映射成了./src/a/b/c/foo.js文件
const foo = require("bar/foo.js"); // bar被映射成了./src/a/b/c/路径
console.log(foo);
module.exports = {
    resolve: {
        alias: {
            "foo": path.resolve(__dirname, "./src/a/b/c/foo.js"),
            "bar": path.resolve(__dirname, "./src/a/b/c/")
        }
    },
}
需要注意的就是,alias可以映射文件也可以映射路径

mainFields: 我们的package.json中可以有多个字段,用于决定优先使用哪个字段来导入模块,比如bootstrap模块中含有js也含有css,其package.json文件中main字段对应的是"dist/js/bootstrap",style字段中对应的是"dist/css/bootstrap.css",我们可以通过设置mainFields字段来改变默认引入,如:

module.exports = {
    resolve: {
        mainFields: ["style", "main"]
    },
}

extensions: 用于设置引入模块的时候,如果没有写模块后缀名,webpack会自动添加后缀去查找,extensions就是用于设置自动添加后缀的顺序,如:

module.exports = {
    resolve: {
        extensions: ["js", "vue"]
    },
}
如果项目中引入了foo模块,require("./foo"),其会优先找./foo.js,如果没有找到./foo.js则会去找./foo.vue文件

六、设置环境变量

设置环境变量需要用到webpack提供的一个内置插件DefinePlugin插件,其作用是将一个字符串值设置为全局变量,如:

module.exports = {
    plugins: [
        new webpack.DefinePlugin({
            "process.env.NODE_ENV": JSON.stringify('development') // 定义全局变量process.env.NODE_ENV值为development
        }),
    ]
}
这样配置之后任何一个模块中都可以直接使用process.env.NODE_ENV变量了,并且其值为'development',与ProvidePlugin有点相似,ProvidePlugin是将一个模块注入到所有模块中实现模块不需要引入即可直接使用

七、webpack优化

优化可以分为优化开发体验优化输出质量两部分。
优化开发体验:

  • 优化构建速度,项目变得庞大复杂的时候,所以需要优化项目的打包时间,比如,缩小文件搜索范围DllPluginHappyPackParallelUglifyPlugin
  • 优化使用体验,通过自动化手段,实现自动刷新模块热替换

优化输出质量:

  • 减少用户能感知到的加载时间,即首屏加载时间,比如区分环境压缩、提取公共代码按需加载CDNTree Shaking
  • 提升流畅度,比如prepackscope hoisting

① 优化Loader配置: 比如babel-loader,这是一个非常耗时的编译过程,由于我们的项目中存在着大量的js文件(项目中大部分都是js文件),所以需要babel-loader处理的js文件非常多,我们可以通过include、exclude来缩小命中范围

module.exports = {
    module: {
        rules: [
            {
                test: /\.js$/,
                use: [{
                    loader: "babel-loader",
                    options: {
                        cacheDirectory: true // 开启缓存
                    }
                }],
                include: path.resolve(__dirname, "src") // 只对项目根目录下的src目录中的js文件进行处理
            }
        ]
    }
}

如果我们的源码中存在.jsx文件,那么我们通常会将test写成/.jsx?&dollar;/,但是如果我们的源码中不存在.jsx文件,那么我们就不要写成/.jsx?&dollar;/,以提高正则表达式性能
cacheDirectory值默认为false ,当设置为true的时候,可以开启缓存,即将babel的编译结果保存到./node_modules/.cache/babel-loader目录下,当第二次打包的时候,如果没有修改js文件,那么就可以直接从缓存中读取编译结果了。即每次修改js文件后(与之前的修改不同),那么都会将新的编译结果缓存到上面的目录中。

也可以通过exclude进行排除,如:

module.exports = {
    module: {
        rules: [
            {
                test: /\.js$/,
                use: [
                    {
                        loader: "babel-loader",
                        options: {
                            cacheDirectory: true // 开启缓存
                        }
                    }
                ],
                exclude: /node_modules/ // 排除node_modules中js的编译
            }
        ]
    }
}

需要注意的是,如果项目中js文件非常少,那么include和exclude效果可能不是很明显,甚至可能变长

② resolve.modules配置: 其默认值为["node_modules"],当第三方模块在./node_modules目录中找不到的时候,会向上一级目录../node_modules中找,再没有就会去../../node_modules中找,所以我们可以指明第三方模块只在当前项目根目录下的node_modules中查找,从而减少搜索步骤

module.exports = {
    resolve: {
        modules: [path.resolve(__dirname, "node_modules")] // 指明第三方模块的存放位置为当前项目根目录下的node_modules,以减少搜索步骤
    }
}

需要注意的是,通常我们的第三方模块都是存放在当前项目根目录下的node_modules目录下的,所以配置了第三方模块存放位置的绝对路径效果不是很明显,因为只有第三方模块找不到的时候才会起作用

③ noParse: 该配置是作为module的一个属性值,即不解析某些模块,所谓不解析,就是不去分析某个模块中的依赖关系,即不去管某个文件是否import(依赖)了某个文件,对于一些独立的库,比如jquery,其根本不存在依赖关系,jquery不会去引入其他的库(要根据自己对某个模块的了解去判断是否要解析该模块),所以我们可以让webpack不去解析jquery的依赖关系,提高打包速度,如:

module.exports = {
    module: {
        noParse:/jquery/,//不去解析jquery中的依赖库
    }
}
noParse是module配置中的一个属性,其属性值为一个正则表达式,填入不被解析的模块名称

为了更清楚的展示noParse的作用,假设我们在入口文件index.js中引入bar.js模块,同时这个bar.js模块中也引入了foo.js模块,foo.js不再依赖其他模块了,那么在不使用noParse的情况下,webpack打包的时候,会先去分析index.js模块,发现其引入了bar.js模块,然后接着分析bar.js模块,发现其引入了foo.js模块,接着分析foo.js模块

Entrypoint index = index.js
[./src/bar.js] 55 bytes {index} [built]
[./src/foo.js] 21 bytes {index} [built]
[./src/index.js] 81 bytes {index} [built]

而此时如果使用了noParse: /bar/,那么webpack打包的时候,会先去分析index.js模块,发现其引入了bar.js模块,但是由于noParse的作用,将不再继续解析bar.js模块了,即不会去分析bar.js中引入的foo.js模块了

Entrypoint index = index.js
[./src/bar.js] 55 bytes {index} [built]
[./src/index.js] 81 bytes {index} [built]

bar.js中引入到的foo.js将不会被打包输出,为了让打包后的文件能够正常运行,所以noParse的模块中不能有import和require

④ 使用DllPlugin: 即动态链接库,由于我们的项目中会存在大量的第三方库文件,比如react和react-dom,而这些库文件只要不升级,是不变的,不需要每次打包都重新打包一遍,所以我们可以通过动态链接库,将这些第三方库文件打包进一个单独的动态链接库文件中,然后告诉webpack动态链接库文件中包含了哪些模块,当打包的时候遇到这些模块就不需要重新打包了,而是直接使用动态链接库中的代码即可。

DllPlugin动态链接库本质上和externals是一样的。我们可以将externals理解成静态链接库,排除对哪些模块的打包是通过externals配置静态写死了的

module.exports = {
    externals: {
        "react": "React",
        "react-dom": "ReactDOM"
    }
}

上一篇中我们已经讲过externals的配置,当项目中引入react和react-dom的时候将会从全局的React和ReactDOM变量中取值。webpack中内置的DllPlugin就是先将react和react-dom打包进一个单独的动态链接库文件中,并且生成一个manifest.json任务清单清单中列出了动态库中包含了哪些模块

// 新建一个webpack.config.dll.js配置文件
module.exports = {
    entry: {
        react: ["react", "react-dom"] // 使用动态链接库必须使用数组的形式
    },
    output: {
        path: path.resolve(__dirname, "dist"),
        filename: "[name].dll.js", // 生成动态链接库文件react.dll.js
        library: "__dll_[name]" // 对外暴露全局变量__dll_react,必须与DllPlugin配置的name同名
    },
    plugins: [
        new webpack.DllPlugin({
            name: "__dll_[name]", // 动态链接库名,即对外暴露的全局变量名
            path: path.resolve(__dirname, "dist", "manifest.json") // 任务清单路径和名称
        })
    ]
}

需要注意的是,DllPlugin中配置的name非常重要,不能乱改,因为这个名字就是对外暴露的全局变量,也就是说全局环境下必须有一个name指定的变量才能正确找到动态链接库

// 执行以下命令打包输出动态链接库文件和任务清单文件
webpack --config ./webpack.config.dll.js
// react.dll.js
var __dll_react = (function (modules) {
    return __webpack_require__(__webpack_require__.s = 0);
})({
    0: (function(module, exports, __webpack_require__) {
        eval("module.exports = __webpack_require__;);
    }
})

从打包输出的动态链接库文件可以看到,其对外暴露了一个全局的变量__dll_react,并且其值为__webpack_require__函数

然后在webpack.config.js中通过DllReferencePlugin引入生成的任务清单文件,即告诉webapck动态链接库中包含了哪些模块,引入这些模块的时候则直接从动态链接库中获取代码。

module.exports = {
    plugins: [
        new webpack.DllReferencePlugin({
            manifest: path.resolve(__dirname, "dist", "manifest.json") // 引入任务清单
        })
    ]
}

我们再来看看打包后的输出结果

(function(modules){})({
    "dll-reference __dll_react": (function(module, exports) {
        module.exports = __dll_react; // 从全局的__dll_react变量中获取值
    }
    "./node_modules/react/index.js": (function(module, exports, __webpack_require__) {
module.exports = (__webpack_require__("dll-reference __dll_react"))("./node_modules/react/index.js");
    }
})

可以看到引入react的时候,变成先引入dll-reference __dll_react模块,而其返回的就是全局的__dll_react变量,这个值又是__webpack_require__函数,所以等价于__webpack_require__("./node_modules/react/index.js"),当动态链接库被引入的时候,动态链接库中包含了./node_modules/react/index.js模块,从而直接从动态库中获取react的代码。

module.exports = {
    externals: {
        "react": "__dll_react('./node_modules/react/index.js')"
    }
}

即引入react的时候,不将react的源码打包输出,而是从全局的__dll_react('./node_modules/react/index.js')中获取。
此时只要我们引入一个文件并对外暴露一个全局的__dll_react函数变函数,传入./node_modules/react/index.js参数能够返回该模块的结果即可。
所以动态链接库和我们通过externals手动分离出库文件效果是一样的

⑤ 使用HappyPack:由于在打包过程中有大量的文件需要交个loader进行处理,包括解析转换等操作,而由于js是单线程的,所以这些文件只能一个一个地处理,而HappyPack的工作原理就是充分发挥CPU的多核功能,将任务分解给多个子进程去并发执行,子进程处理完后再将结果发送给主进程,happypack主要起到一个任务劫持的作用,在创建HappyPack实例的时候要传入对应文件的loader,即use部分loader配置中将使用经过HappyPack包装后的loader进行处理,如:

const HappyPack = require("happypack"); // 安装并引入happypack模块
module.exports = {
    plugins: [
        new HappyPack({ // 这里对处理css文件的loader进行包装
            id: "css",// 之前的loader根据具体的id进行引入
            use: ["style-loader","css-loader"],
            threads: 5 // 设置开启的进程数
        })
    ],
    module: {
        rules: [
            {
                test: /\.css$/, // 匹配以.css结尾的文件
                use: ["happypack/loader?id=css"] //根据happypack实例中配置的id引入包装后的laoder,这里的happyPack的h可以大写也可以小写
            }
        ]
    }
}
webpack要打包的文件非常多的时候才需要使用happypack进行优化,因为开启多进程也是需要耗时间的,所以文件少的时候,使用happypack返回更耗时

⑥ 文件监听: 当源码发生变化后,通过文件监听的方式,只打包编译变化的部分,从而大幅减少打包时间,同时提升使用体验。
如果需要开启该功能,那么需要将watch设置为true当webpack启动后就会进入监听模式,监听文件变化,并打包编译变化的部分。具体监听配置通过watchOptions进行相应的设置。

module.exports = {
    watch: true, // 默认为false,只有将watch设置为true,watchOptions才会有意义
    watchOptions: {
        poll: 1000, // 每隔一秒轮询一次文件是否发生变化
        aggregateTimeout: 1000, // 当第一个文件更改,会在重新构建前增加延迟。这个选项允许 webpack 将这段时间内进行的任何其他更改都聚合到一次重新构建里,即监听到变化后1秒后开始打包编译
        ignored: /node_modules/ // 排除对node_modules中文件的监听
    }
}

webpack默认会监听entry中配置的入口文件以及入口文件的依赖文件,而这些依赖有很多是存在于node_modules中的第三方模块,这些文件一般是不会发生变化的,所以我们不需要监听node_modules中的文件

⑦ 自动刷新: 通过webpack-dev-server启动webpack,会自动开启webpack的文件监听,当webpack监听到文件发生变化的时候,会通知webpack-dev-server模块

webpack自动刷新的实现方式:

  • 向每个输出的chunk中注入代理客户端代码,通过代理客户端去刷新整个页面;
  • 向网页中注入一个iframe,通过刷新iframe去看到最新的效果;

webpack默认情况下采用的是向每个输出的chunk中注入代理客户端代码,当我们的项目中要输出很多chunk的时候,就会导致构建缓慢,可以通过devServer.inline的配置来控制是否向chunk中注入代理客户端

如果将devServer.inline设置为false,那么打包输出的chunk中将不会被注入代理客户端代码,而此时要实现自动刷新功能,那么必须使用新的访问地址,即在原来链接后面加上webpack-dev-server路径,如,http://localhost:3000/webpack-dev-server/,该页面会在网页中嵌入一个iframe,通过iframe去刷新页面,如:

<iframe id="iframe" src="http://localhost:3000/" allowfullscreen="allowfullscreen" style=""></iframe>

如果还想使用原先的地址,那么必须在页面中引入webpack-dev-server.js脚本,如:

// 在html模板页面中加入该脚本
<script src="http://localhost:3000/webpack-dev-server.js"></script>

⑧ 开启模块热更新: 模块热更新可以做到在不刷新网页的情况下,更新修改的模块,只编译变化的模块,而不用全部模块重新打包,大大提高开发效率,在未开启热更新的情况下,每次修改了模块,都会重新打包。要开启模块热更新,那么只需要在devServer配置中添加hot:true即可。当然仅仅开启模块热更新是不够的,我们需要做一些类似监听的操作,当监听的模块发生变化的时候,重新加载该模块并执行,如:

module.exports = {
    devServer: {
        hot: true // 开启热更新
    }
}

----------


import foo from "./foo";
console.log(foo);
if (module.hot) {
    module.hot.accept("./foo", () => { // 监听到foo模块发生变化的时候
        const foo =  require("./foo"); // 重新引入该模块并执行
        console.log(foo);
    });
}
如果不使用module.hot.accept监听,那么当修改foo模块的时候还是会刷新页面的。
不要将热模块替换技术应用于线上环境,它是专门为了提升开发体验而生的。

⑨ 抽离公共模块: 对于多入口情况,如果某个或某些模块,被两个以上文件所依赖,那么可以将这个模块单独抽离出来,不需要将这些公共的代码都打包进每个输出文件中,这样会造成代码的重复和流量的浪费,即如果有两个入口文件index.js和other.js,它们都依赖了foo.js,那么如果不抽离公共模块,那么foo.js中的代码都会打包进最终输出的index.js和other.js中去,即有两份foo.js了。抽离公共模块也很简单,直接在optimization中配置即可,如:

module.exports = {
    optimization: {
        splitChunks: { // 分割代码块,即抽离公共模块
            cacheGroups: { // 缓存组
                common: { // 组名为common可自定义
                    chunks: "initial", // 同步引入
                    minSize: 0, // 文件大小为0字节以上才抽离
                    minChunks: 2, // 多个入口文件的时候,被引用过两次才抽离
                    name: "common/foo", // 定义抽离出的文件的名称
                }
            }
        }
    }
}
这样就会将公共的foo.js模块抽离到common目录下foo.js中了,但是这相当于所有被引入过两次的模块都会被单独打包到common/foo.js中,如果我们也有多个文件依赖了第三方模块如jquery,那么jquery也会被打包进foo.js中,会导致代码混乱,所以我们希望将jquery单独抽出来,即与foo.js分开,我们可以复制一份以上配置,并通过设置test属性进行匹配,或者设置抽离代码权重的方式来实现,即优先抽离出jquery,如:
// 通过配置test属性,进行精确匹配来实现不同模块的单独抽离
module.exports = {
    optimization: {
        splitChunks: { // 分割代码块,即抽离公共模块
            cacheGroups: { // 缓存组
                common: { // 组名为common可自定义
                    test: /foo.js/, // 用于匹配哪些文件需要被抽离,如果没有配置test属性,那么将会匹配所有文件
                    chunks: "initial", // 同步引入
                    minSize: 0, // 文件大小为0字节以上才抽离
                    minChunks: 2, // 被引用过两次才抽离
                    name: "common/foo", // 定义抽离出的文件的名称
                },
                verdor: {
                    test: /jquery/, // 引入的jquery模块
                    chunks: "initial", // 同步引入
                    minSize: 0, // 文件大小为0字节以上才抽离
                    minChunks: 1, // 被引用过一次就抽离
                    name: "verdor/jquery", // 定义抽离出的文件的名称
                }
            }
        }    
    }
}
// 通过配置priority属性,设置权重级别的高低来实现不同模块的单独抽离
module.exports = {
    optimization: {
        splitChunks: { // 分割代码块,即抽离公共模块
            cacheGroups: { // 缓存组
                common: { // 组名为common可自定义
                    priority: 0, // 默认为0
                    chunks: "initial", // 同步引入
                    minSize: 0, // 文件大小为0字节以上才抽离
                    minChunks: 2, // 被引用过两次才抽离
                    name: "common/foo", // 定义抽离出的文件的名称
                },
                verdor: {
                    test: /jquery/, // 引入的jquery模块
                    priority: 1, // 设置打包权重,即优先抽离jquery模块
                    chunks: "initial", // 同步引入
                    minSize: 0, // 文件大小为0字节以上才抽离
                    minChunks: 1, // 被引用过一次就抽离
                    name: "verdor/jquery", // 定义抽离出的文件的名称
                }
            }
        }    
    }
}
这样foo.js就会被抽离到common/foo.js文件中,jquery就会被抽离到vendor/jquery.js中了,需要注意的是,代码的抽离必须是该模块没有被排除打包,即该模块会被打包进输出bundle中,如果第三方模块已经通过externals排除打包,则以上vendor配置无效。

⑩ 按需加载,即在需要使用的时候才加载,webpack提供了import()方法,传入要动态加载的模块,来动态加载指定的模块,当webpack遇到import()语句的时候,不会立即去加载该模块,而是在用到该模块的时候,再去加载,也就是说打包的时候会一起打包出来,但是在浏览器中加载的时候并不会立即加载,而是等到用到的时候再去加载,比如,点击按钮后才会加载某个模块,如:

const button = document.createElement("button");
button.innerText = "点我"
button.addEventListener("click", () => { // 点击按钮后加载foo.js
    import("./foo").then((res) => { // import()返回的是一个Promise对象
        console.log(res);
    });
});
document.body.appendChild(button);
从中可以看到,import()返回的是一个Promise对象,其主要就是利用JSONP实现动态加载,返回的res结果不同的export方式会有不同,如果使用的module.exports输出,那么返回的res就是module.exports输出的结果;如果使用的是ES6模块输出,即export default输出,那么返回的res结果就是res.default,如:

// ES6模块输出,res结果为

{default: "foo", __esModule: true, Symbol(Symbol.toStringTag): "Module"}

默认情况下,import()方法动态引入的模块名为一个数字,并且从0开始,我们可以在import()方法中添加一些魔法注释来指定动态引入的模块名,如:

setTimeout(() => {
    // 注意魔法注释的名称区分大小写固定为webpackChunkName,值为一个字符串,并且必须带上双引号
    import(/*webpackChunkName: "util"*/"./util.js");
}, 3000);

此时打包出的动态模块将变成util.js。
我们也可以再添加一个魔法注释将webpackPrefetch设置为true,那么就会进行预加载该动态模块,如:

setTimeout(() => {

    import(/*webpackChunkName: "util"*/ /*webpackPrefetch: true*/"./util.js");
}, 3000);

此时页面一加载就会进入预加载引入该模块,如:

<!--通过prefetch方式预加载该动态模块-->
<link rel="prefetch" as="script" href="static/js/util.a79.js">

对于动态模块,我们还可以通过output.chunkFilename来控制打包后的路径和文件名,如:

module.exports = {
    output: {
        chunkFilename: "static/js/[name].[chunkhash:3].js"
    }
}

使用Scope Hoisting: Scope Hoisting可以让Webpack打包出来的代码文件更小运行更快。因为使用Scope Hoisting之后,webpack会将模块进行关联将相关联的模块合并到一起,从而在运行的时候可以减少函数作用域的创建,减少内存开销,同时由于模块关联合并后代码体积也会相应减少。

要开启Scope Hoisting,需要使用到模块关联插件,webpack已经内置了模块关联插件,即webpack.optimize.ModuleConcatenationPlugin,创建插件对象即可,如:

// util.js
export default "hello webpack";
// index.js
import str from "./util.js"
console.log(str);
module.exports = {
    plugins: [
        new webpack.optimize.ModuleConcatenationPlugin() // 开启Scope Hoisting作用域提升
    ]
}

打包输出结果如下:

(function(modules) {})({
    "./src/index.js": (function(module, __webpack_exports__, __webpack_require__) {
        var util = ("hello webpack");
        console.log(util);
    })
})

可以看到经过Scope Hoisting之后,直接将util.js中的内容关联合并到了index.js中,从而减少了代码体积,运行的时候也可以减少函数作用域的创建。

再看看未使用Scope Hoisting的打包输出:

(function(modules) {})({
    "./src/util.js": (function(module, __webpack_exports__, __webpack_require__) {
        __webpack_exports__["default"] = ("hello webpack");
    }),
    "./src/index.js": (function(module, __webpack_exports__, __webpack_require__) {
        var _util__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./util */ "./src/util.js");
        console.log(_util__WEBPACK_IMPORTED_MODULE_0__["default"]);
    })
})

可以看到,打包输出代码明显更多,并且运行的时候还需要创建./src/util.js的函数作用域来引入该模块并获取该模块的输出结果。

webpack4中已经将Scope Hoisting作为了一个内置优化项,如:

module.exports = {
    optimization: {
        concatenateModules: true // 开启Scope Hoisting
    }
}

需要注意的是,Scope Hoisting 仅支持ES模块

⑫ 开启Tree Shaking(摇树): 可以对未使用到的代码或者死代码通过摇树的方式去除掉,可以进一步减小打包后代码的体积生产模式下会自动开启Trees Shaking功能。比如有一个工具类导出了很多方法,但是我们只使用到了其中的一个方法,那么我们就可以开启Tree Shaking去除那些未使用到的代码,如:

// ./src/util.js
export function foo() {
  return "foo";
  console.log("dead code"); // 死代码
}
export function bar() { // 未使用代码
  console.log("bar");
}
// ./src/index.js
import {foo} from "./util"
foo();

webpack4已经将Tree Shaking作为了一个内置的优化选项,我们只需要配置optimization.usedExports为true即可开启。

module.exports = {
    mode: "none",
    optimization: {
        usedExports: true // 只导出外部使用到的代码
    }
}

此时再次执行webapck打包,可以看到foo函数中的死代码以及bar函数都变灰色显示了,但是这些代码还是被打包进了最终的代码中,因为usedExports只是标记哪些代码是不可用的,即树上的枯叶。接着我们还需要开启JS代码的压缩功能,那些不可用的代码经过压缩后就会被自动去除了。

module.exports = {
    mode: "none",
    optimization: {
        usedExports: true, // 只导出外部使用到的代码
        minimize: true // 开启JS压缩去除未使用的代码
    }
}

如果以上两个配置开启后不生效,那么我们需要修改一下.babelrc文件,因为Tree Shaking只支持ES Module,如:

// .babelrc
{
    "presets": [
        ["@babel/preset-env", {"modules": false}]
    ]
}

⑬ 使用IgnorePlugin: 可以忽略某个模块中某些目录中的模块引用,比如在引入某个模块的时候,该模块会引入大量的语言包,而我们不会用到那么多语言包,如果都打包进项目中,那么就会影响打包速度和最终包的大小,然后再引入需要使用的语言包即可,如:
项目根目录下有一个time包,其中有一个lang包,lang包中包含了各种语言输出对应时间的js文件,time
包下的index.js会引入lang包下所有的js文件,那么当我们引入time模块的时候,就会将lang包下的所有js文件都打包进去,添加如下配置:

const webpack = require("webpack");
module.exports = {
    plugins: [
        new webpack.IgnorePlugin(/lang/, /time/)
    ]
}
引入time模块的时候,如果time模块中引入了其中的lang模块中的内容,那么就忽略掉,即不引入lang模块中的内容,需要注意的是,这/time/只是匹配文件夹和time模块的具体目录位置无关,即只要是引入了目录名为time中的内容就会生效
阅读 8.9k

推荐阅读
前海拾贝
用户专栏

徜徉前端的海洋,拾取晶莹的贝壳。

3292 人关注
54 篇文章
专栏主页