前端工程化经历过很多优秀的工具,例如 Grunt、Gulp、webpack、rollup 等等,每种工具都有自己适用的场景,而现今应用最为广泛的当属 weback 打包了。因此 webpack 也自然而然成了面试官打探你是否懂前端工程化的重要指标。
由于 webpack 技术栈比较复杂,因此决定分以下几篇文章全面深入的讲解:
基础应用篇
高级应用篇
性能优化篇
原理篇( webpack 框架执行流程、手写 plugin、手写 loader )
webpack 是什么
webpack 是模块打包工具
webpack 可以不进行任何配置(不进行任何配置时,webpack 会使用默认配置)打包如下代码:
// moduleA.js
function ModuleA(){
this.a = "a";
this.b = "b";
}
export default ModuleA
// index.js
import ModuleA from "./moduleA.js";
const module = new ModuleA();
复制代码
我们知道浏览器是不认识的 import 语法的,直接在浏览器中运行这样的代码会报错。那么我们就可以借助 webpack 来打包这样的代码,赋予 JavaScript 模块化的能力。
最初版本的 webpack 只能打包 JavaScript 代码,随着发展 css 文件,图片文件,字体文件都可以被 webpack 打包。
本文将主要讲解 webpack 是如何打包这些资源的,属于比较基础的文章主要是为了后面讲解性能优化和原理做铺垫,如果已经对 webpack 比较熟悉的同学可以跳过本文。
webpack 基础功能
初始化安装 webpack
mkdir webpackDemo // 创建文件夹
cd webpackDemo // 进入文件夹
npm init -y // 初始化package.json
npm install webpack webpack-cli -D // 开发环境安装 webpack 以及 webpack-cli
复制代码
通过这样安装之后,我们可以在项目中使用 webpack 命令了。
打包第一个文件
webpack.config.js
const path = require('path');
module.exports = {
mode: 'development', // {1}
entry: { // {2}
main:'./src/index.js'
},
output: { // {3}
publicPath:"", // 所有dist文件添加统一的前缀地址,例如发布到cdn的域名就在这里统一添加
filename: 'bundle.js',
path: path.resolve(__dirname,'dist')
}
}
复制代码
代码分析:
development | production
[注意] 这个基础的配置文件哪怕你不写,我们执行 webpack 命令也可以运行,那是因为 webpack 提供了一个默认配置文件。
创建文件进行简单打包:
src/moduleA.js
const moduleA = function () {
return "moduleA"
}
export default moduleA;
--------------------------------
src/index.js
import moduleA from "./moduleA";
console.log(moduleA());
复制代码
修改npm run build
命令
"scripts": {
"build": "webpack --config webpack.config.js"
}
复制代码
执行 npm run build 命令
打包后的 bundle.js 源码分析
源码经过简化,只把核心部分展示出来,方便理解
var installedModules = {};
function __webpack_require__(moduleId) {
// 缓存文件
if(installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// 初始化 moudle,并且也在缓存中存入一份
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
// 执行 "./src/index.js" 对应的函数体
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// 标记"./src/index.js"该模块以及加载
module.l = true;
// 返回已经加载成功的模块
return module.exports;
}
// 匿名函数开始执行的位置,并且默认路径就是入口文件
return __webpack_require__(__webpack_require__.s = "./src/index.js");
})
// 传入匿名执行函数体的module对象,包含"./src/index.js","./src/moduleA.js"
// 以及它们对应要执行的函数体
({
"./src/index.js": (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _moduleA__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./moduleA */ \"./src/moduleA.js\");\n\n\nconsole.log(Object(_moduleA__WEBPACK_IMPORTED_MODULE_0__[\"default\"])());\n\n\n//# sourceURL=webpack:///./src/index.js?");
}),
"./src/moduleA.js": (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\nconst moduleA = function () {\n return \"moduleA\"\n}\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (moduleA);\n\n\n//# sourceURL=webpack:///./src/moduleA.js?");
})
});
复制代码
再来看看"./src/index.js"
对应的执行函数
(function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _moduleA__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./moduleA */ \"./src/moduleA.js\");\n\n\nconsole.log(Object(_moduleA__WEBPACK_IMPORTED_MODULE_0__[\"default\"])());\n\n\n//# sourceURL=webpack:///./src/index.js?");
})
复制代码
你会发现其实就是一个 eval 执行方法
我们拆开 eval 来仔细看看里面是什么内容,简化后代码如下:
var moduleA = __webpack_require__("./src/moduleA.js");
console.log(Object(moduleA["default"])());
复制代码
上面源码中其实已经调用了__webpack_require__(__webpack_require__.s = "./src/index.js");
,然后 "./src/index.js"
又递归调用了去获取"./src/moduleA.js" ````的输出对象。 我们看看
"./src/moduleA.js" ```代码会输出什么:
const moduleA = function () {
return "moduleA"
}
__webpack_exports__["default"] = (moduleA);
复制代码
再回头看看上面的代码就相当于:
console.log(Object(function () {
return "moduleA"
})());
复制代码
最后执行打印了"moduleA"
通过这段源码的分析可以看出:
打包之后的模块,都是通过 eval 函数进行执行的;
通过调用入口函数./src/index.js
然后递归的去把所有模块找到,由于递归的一个缺点,会进行重复计算,因此 __webpack_require__
函数中有一个缓存对象installedModules
来处理这个问题。
loader
我们知道 webpack 可以打包 JavaScript 模块,而且也早就听说 webpack 还可以打包图片、字体以及 css,这个时候就需要 loader 来帮助我们识别这些文件了。
[注意] 碰到文件不能识别记得找 loader 即可。
打包图片文件
修改配置文件:webpack.config.js
module.exports = {
mode: 'development',
entry: {
main:'./src/index.js'
},
output: {
filename: 'bundle.js',
path: path.resolve(__dirname,'dist')
},
module:{
rules:[
{
test:/\.(png|svg|jpg|gif)$/,
use:{
loader: 'url-loader',
options: {
name: '[name]_[hash].[ext]',
outputPath:"images", // 打包该资源到 images 文件夹下
limit: 2048 // 如果图片的大小,小于2048KB则时输出base64,否则输出图片
}
}
}
]
}
}
复制代码
修改:src/index.js
import moduleA from "./moduleA";
import header from "./header.jpg";
function insertImg(){
const imageElement = new Image();
imageElement.src = `dist/${header}`;
document.body.appendChild(imageElement);
}
insertImg();
复制代码
执行打包后,发现可以正常打包,并且 dist 目录下也多出了一个图片文件。
我们简单分析下:
webpack 本身其实只认识 JavaScript 模块的,当碰到图片文件时便会去 module 的配置 rules 中找,发现 test:/\.(png|svg|jpg|gif)$/
,正则匹配到图片文件后缀时就使用url-loader
进行处理,如果图片小于2048KB
(这个可以设置成任意值,主要看项目)就输出base64
。
打包样式文件
{
test:/\.scss$/, // 正则匹配到.scss样式文件
use:[
'style-loader', // 把得到的CSS内容插入到HTML中
{
loader: 'css-loader',
options: {
importLoaders: 2, // scss中再次import scss文件,也同样执行 sass-loader 和 postcss-loader
modules: true // 启用 css module
}
},
'sass-loader', // 解析 scss 文件成 css 文件
'postcss-loader'// 自动增加厂商前缀 -webket -moz,使用它还需要创建postcss.config.js配置文件
]
}
复制代码
postcss.config.js
module.exports = {
plugins: [
require('autoprefixer')
]
};
复制代码
打包解析:
当 webpack 遇到xx.scss
样式文件;
依次调用postcss-loader
自动增加厂商前缀-webket -moz
;
调用 sass-loader
把 scss 文件转换成 css 文件;
调用css-loader
处理 css 文件,其中 importLoaders:2 ,是 scss 文件中引入了其它 scss 文件,需要重复调用 sass-loader``````ostcss-loader
的配置项;
最后调用style-loader
把前面编译好的 css 文件内容以 形式插入到页面中。
[注意] loader的执行顺序是数组后到前的执行顺序。
打包字体文件
{
test: /\.(woff|woff2|eot|ttf|otf)$/, // 打包字体文件
use: ['file-loader'] // 把字体文件移动到dist目录下
}
复制代码
plugins
plugins 可以在 webpack 运行到某个时刻帮你做一些事情,相当于 webpack 在某一个生命周期插件做一些辅助的事情。
html-webpack-plugin
作用:
会在打包结束后,自动生产一个 HTML 文件(也可通过模板生成),并把打包生成的 JS 文件自动引入到 HTML 文件中。
使用:
const HtmlWebpackPlugin = require('html-webpack-plugin');
plugins: [
new HtmlWebpackPlugin({
template: 'src/index.html' // 使用模板文件
})
]
复制代码
clean-webpack-plugin
作用:
每次输出打包结果时,先自动删除 output 配置的文件夹
使用:
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
plugins: [
...
new CleanWebpackPlugin() // 使用这个插件在每次生成dist目录前,先删除dist目录
]
复制代码
source map
在开发过程中有一个功能是很重要的,那就是错误调试,我们在编写代码过程中出现了错误,编译后的包如果提示不友好,将会严重影响我们的开发效率。而通过配置 source map 就可以帮助我们解决这个问题。
示例: 修改:src/index.js,增加一行错误的代码
console.log(a);
复制代码
由于mode: 'development'
开发模式是默认会打开source map功能的,我们先关闭它。
devtool: 'none' // 关闭source map 配置
复制代码
执行打包来看下控制台的报错信息:
错误堆栈信息,竟然给的是打包之后的 bundle 文件中的信息,但其实我们在开发过程中的文件结构并不是这样的,因此我们需要它能指明我们是在 index.js 中的多少行发生错误了,这样我们就可以快速的定位到问题。
我们去掉devtool:'none'
这行配置,再执行打包:
此时它就把我们在开发中的具体错误文件在错误堆栈中输出了,这就是source map的功能。
总结下:source map 它是一个映射关系,它知道 dist 目录下 bundle.js 文件对应的实际是 index.js 文件中的多少行。
webpackDevServer
每次修改完代码之后都要手动去执行编译命令,这显然是不科学的,我们希望是每次写完代码,webpack 会进行自动编译,webpackDevServer 就可以帮助我们。
增加配置:
devServer: {
contentBase: './dist', // 服务器启动根目录设置为dist
open: true, // 自动打开浏览器
port: 8081 // 配置服务启动端口,默认是8080
},
复制代码
它相当于帮助我们开启了一个 web 服务,并监听了 src 下文件当文件有变动时,自动帮助我们进行重新执行 webpack 编译。
我们在package.json
中增加一条命令:
"start": "webpack-dev-server"
},
复制代码
现在我们执行npm start
命令后,可以看到控制台开始实行监听模式了,此时我们任意更改业务代码,都会触发 webpack 重新编译。
手动实现简单版 webpack-dev-server
项目根目录下增加: server.js
加载包:npm install express webpack-dev-middleware -D
const express = require('express');
const app = express();
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const config = require('./webpack.config.js'); // 引入webpack配置文件
const compiler = webpack(config); // webpack 编译运行时
// 告诉 express 使用 webpack-dev-middleware,
// 以及将 webpack.config.js 配置文件作为基础配置
app.use(webpackDevMiddleware(compiler, {}));
// 监听端口
app.listen(3000,()=>{
console.log('程序已启动在3000端口');
});
复制代码
webpack-dev-middleware
作用:
1.通过 watch mode
监听资源的变更然后自动打包,本质上是调用compiler
对象上的 watch 方法;
2.使用内存文件系统编译速度快compiler.outputFileSystem = new MemoryFileSystem()
;
3.返回 express 框架可用的中间件。
package.json 增加一条命令:
"scripts": {
"server": "node server.js"
},
复制代码
执行命令npm run server
启动我们自定义的服务,浏览器中输入http://localhost:3000/
查看效果。
热更新 Hot Moudule Replacement(HMR)
模块热更新功能会在应用程序运行过程中,替换、添加或删除模块,而无需重新加载整个页面。
HMR 配置
const webpack = require('webpack');
module.exports = {
devServer: {
contentBase: './dist',
open: true,
port: 8081,
hot: true // 热更新配置
},
plugins:[
new webpack.HotModuleReplacementPlugin() // 增加热更新插件
]
}
复制代码
手动编写 HMR 代码
在编写代码时经常会发现热更新失效,那是因为相应的 loader 没有去实现热更新,我们看看如何简单实现一个热更新。
import moduleA from "./moduleA";
if (module.hot) {
module.hot.accept('./moduleA.js', function() {
console.log("moduleA 支持热更新拉");
console.log(moduleA());
})
}
复制代码
代码解释: 我们引人自己编写的一个普通 ES6 语法模块,加入我们想要实现热更新就必须手动监听相关文件,然后当接收到更新回调时,主动调用。
还记得上面讲 webpack 打包后的源码分析吗,webpack 给模块都建立了一个 module 对象,当你开启模块热更新时,在初始化 module 对象时增加了(源码经过删减):
function hotCreateModule(moduleId) {
var hot = {
active: true,
accept: function(dep, callback){
if (dep === undefined) hot._selfAccepted = true;
else if (typeof dep === "function") hot._selfAccepted = dep;
else if (typeof dep === "object")
for (var i = 0; i < dep.length; i++)
hot._acceptedDependencies[dep[i]] = callback || function() {};
else hot._acceptedDependencies[dep] = callback || function() {};
}
}
}
复制代码
module 对象中保存了监听文件路径和回调函数的依赖表,当监听的模块发生变更后,会去主动调用相关的回调函数,实现手动热更新。
[注意] 所有编写的业务模块,最终都会被 webpack 转换成 module 对象进行管理,如果开启热更新,那么 module 就会去增加 hot 相关属性。这些属性构成了 webpack 编译运行时对象。
编译 ES6
显然大家都知道必须要使用 babel 来支持了,我们具体看看如何配置
配置
1、安装相关包
npm install babel-loader @babel/core @babel/preset-env @babel/polyfill -D
复制代码
2、修改配置 webpack.config.json
还记得文章上面说过,碰到不认识的文件类型的编译问题要求助 loader
module:{
rules:[
{
test: /\.js$/, // 正则匹配js文件
exclude: /node_modules/, // 排除 node_modules 文件夹
loader: "babel-loader", // 使用 babel-loader
options:{
presets:[
[
"@babel/preset-env", // {1}
{ useBuiltIns: "usage" } // {2}
]
]
}
}
]
}
复制代码
babel 配置解析:
{1} babel presets 是一组插件的集合,它的作用是转换 ES6+ 的新语法,但是一些新 API 它不会处理的
Promise Generator
是新语法
Array.prototype.map
方法是新 API ,babel 是不会转换这个语法的,因此需要借助polyfill
处理
{2}useBuiltIns
的配置是处理@babel/polyfill
如何加载的,它有3个值false ``````entry ``````usage
entry
: 根据 target
中浏览器版本的支持,将 polyfills
拆分引入,仅引入有浏览器不支持的 polyfill
usage
:检测代码中ES6/7/8
等的使用情况,仅仅加载代码中用到的polyfills
演示
新建文件 src/moduleES6.js
const arr = [
new Promise(()=>{}),
new Promise(()=>{})
];
function handleArr(){
arr.map((item)=>{
console.log(item);
});
}
export default handleArr;
复制代码
修改文件 src/index.js
import moduleES6 from "./moduleES6";
moduleES6();
复制代码
执行打包后的源文件(简化后):
"./node_modules/core-js/modules/es6.array.map.js":
(function(module, exports, __webpack_require__) {
"use strict";
var $export = __webpack_require__("./node_modules/core-js/modules/_export.js");
var $map = __webpack_require__("./node_modules/core-js/modules/_array-methods.js")(1);
$export($export.P + $export.F * !__webpack_require__(/*! ./_strict-method */ "./node_modules/core-js/modules/_strict-method.js")([].map, true), 'Array', {
map: function map(callbackfn) {
return $map(this, callbackfn, arguments[1]);
}
});
复制代码
看代码就应该能明白了 polyfill 相当于是使用 ES5 的语法重新实现了 map 方法来兼容低版本浏览器。
而 polyfill 实现了 ES6-ES10 所有的语法十分庞大,我们不可能全部引入,因此才会有这个配置 useBuiltIns: "usage"
只加载使用的语法。
编译 React 文件
配置
安装相关依赖包
npm install @babel/preset-react -D
npm install react react-dom
复制代码
webpack.config.js
module:{
rules:[
{
test: /\.js$/, // 正则匹配js文件
exclude: /node_modules/, // 排除 node_modules 文件夹
loader: "babel-loader", // 使用 babel-loader
options:{
presets:[
[
"@babel/preset-env",
{ useBuiltIns: "usage" }
],
["@babel/preset-react"]
]
}
}
]
}
复制代码
直接在 presets 配置中增加一个["@babel/preset-react"]
配置即可, 那么这个 preset 就会帮助我们把 React 中 JSX 语法转换成 React.createElement 这样的语法。
演示
修改文件: src/index.js
import React,{Component} from 'react';
import ReactDom from 'react-dom';
class App extends Component{
render(){
const arr = [1,2,3,4];
return (
arr.map((item)=>num: {item}
)
)
}
}
ReactDom.render( , document.getElementById('root'));
复制代码
执行打包命令 yarn build
可以正确打包并且显示正常界面。
随着项目的复杂度增加,babel 的配置也随之变的复杂,因此我们需要把 babel 相关的配置提取成一个单独的文件进行配置方便管理,也就是我们工程目录下的 .babelrc
文件。
.babelrc
{
"presets":[
["@babel/preset-env",{ "useBuiltIns": "usage" }],
["@babel/preset-react"]
]
}
复制代码
[注意] babel-laoder
执行presets
配置顺序是数组的后到前,与同时使用多个 loader 的执行顺序是一样的。
也就是把webpack.config.js
中的babel-loader
中的 options
对象提取成一个单独文件。
通过编译记录,我们可以发现一个问题就是打包后的 bundle.js 文件足足有1M大,那是因为 react 以及 react-dom 都被打包进来了。
有想了解更多的小伙伴可以加Q群链接里面看一下,应该对你们能够有所帮助。