前端面试的那些事儿 ~ 深入浅出 webpack 之基础应用篇

前端工程化经历过很多优秀的工具,例如 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());
复制代码

修改package.jsonsrcipts

"scripts": {
  "build": "webpack --config webpack.config.js"
}
复制代码

执行 npm run build 命令

打包后的 bundle.js 源码分析
源码经过简化,只把核心部分展示出来,方便理解

(function(modules) {
 	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"

通过这段源码的分析可以看出:

1.打包之后的模块,都是通过 eval 函数进行执行的;
2.通过调用入口函数 ./src/index.js然后递归的去把所有模块找到,由于递归的一个缺点,会进行重复计算,因此__webpack_require__函数中有一个缓存对象` ``installedModules ```来处理这个问题。

loader
我们知道 webpack 可以打包 JavaScript 模块,而且也早就听说 webpack 还可以打包图片、字体以及 css,这个时候就需要 loader 来帮助我们识别这些文件了。

[注意] 碰到文件不能识别记得找 loader 即可。
打包图片文件

修改配置文件:webpack.config.js

const path = require('path');

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')
  ]
};
复制代码

打包解析:

1.当 webpack 遇到xx.scss样式文件;
2.依次调用postcss-loader自动增加厂商前缀-webket -moz
3.调用sass-loader把 scss 文件转换成 css 文件;
4.调用css-loader处理 css 文件,其中 importLoaders:2 ,是 scss 文件中引入了其它 scss 文件,需要.重复调用sass-loader``````postcss-loader的配置项;
5.最后调用 style-loader把前面编译好的 css 文件内容以 <style>...</style>形式插入到页面中。

[注意] 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中增加一条命令:

"scripts": {
 	"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
false: 不对 polyfills做任何操作;
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)=><p>num: {item}</p>)
    )
  }
}

ReactDom.render(<App />, document.getElementById('root'));
复制代码

执行打包命令 yarn build可以正确打包并且显示正常界面。

随着项目的复杂度增加,babel 的配置也随之变的复杂,因此我们需要把 babel 相关的配置提取成一个单独的文件进行配置方便管理,也就是我们工程目录下的 .babelrc文件。

.babelrc

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)=><p>num: {item}</p>)
    )
  }
}

ReactDom.render(<App />, 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 都被打包进来了,webpack 优化的文章中会讲解如何 code-splitting
从而进行优化。

如果你现在也想学习前端开发技术,在学习前端的过程当中有遇见任何关于学习方法,学习路线,学习效率等方面的问题,你都可以加入到我的Q群中:前114中6649后671,里面有许多前端学习资料以及2020大厂面试真题 点赞、评论、转发 即可免费获取,希望能够对你们有所帮助。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值