跟着官方文档学习webpack

1,435 阅读12分钟

前言

webpack在日常开发中常常会用到,但在开发中只是了解一些基本配置,并没有深入的去学习每个插件的作用是什么。这次读取了一遍webpack的文档,写下这篇文章也是对自己学习内容的一个梳理过程。

webpack到底是什么?

我们经常使用的import,在浏览器中无法加载Cannot use import statement outside a module 原因是浏览器无法识别import语法,此时需要webpack来翻译一下,将import变为浏览器能够认识的语法

npm init -y;
// webpack是核心模块
// webpack-cli是帮助我们用命令行运行webpack的
yarn add webpack webpack-cli;
// 用webpack翻译index.js
npx webpack ./index.js

执行完上面语句后,会自动在根目录生成一个dist/main.js

所以在这里,webpack起到了一个转换器的作用。但说webpack是一个转换器还不太准确,因为webpack只能识别import这类简单的es6语法。

我们去webpack官网看下webpack的定义。webpack.docschina.org/concepts/

image.png

官网给出的答案是:webpack是一个模块打包工具。

// Header
export default function () {
  console.log("我是header");
}

// Sider
export default function () {
  console.log("我是sider");
}


import Header from "./Header";
import Sider from "./Sider";

Header();
Sider();

如上所示,Header和Sider各自算作一个模块,执行完webpack后,生成的main.js文件如下所示,webpack将不同模块内的内容打包成一个文件,这就是webpack的主要作用

(()=>{"use strict";console.log("我是header"),console.log("我是sider")})();

我们上面提到的import是ES Module模块引入方式,我们还有Common.js模块规范,AMD,CMD规范,webpack同样可以识别。

// Common.js模块引入方式

// Header.js
module.exports = function () {
  console.log("我是header");
};

// Sider.js
module.exports = function () {
  console.log("我是sider");
};

const Header = require("./Header");
const Sider = require("./Sider");

Header();
Sider();

webpack配置文件

webpack并没有那么智能,他不能独立完成项目的打包,它需要一个配置文件来指导webpack的处理项目。我们前面运行npx webpack ./index.js之所以不需要配置文件,是因为webpack会有一些默认的配置下来帮助我们完成这些操作。

比如前面我们运行npx webpack index.js,此处如果不指定index.js则会报错,因为webpack不知道从哪里开始编译。有了配置文件,我们在运行时则不需要指定入口文件了,webpack会自从去配置文件中检索。

webpack.config.js

配置文件就是根目录下的webpack.config.jswebpack.config.js是我们的默认文件名,如果我们想定义其他的文件名,需要在package.json中单独配置一下。

  "scripts": {
    "dev": "webpack --config webpackconfig.js"
  },

我们先来定义一个简单的配置文件

// webpack.config.js

const path = require("path");

module.exports = {
  mode: "development",
  // 入口
  entry: "./index.js",
  // 出口
  output: {
    path: path.resolve(__dirname, "bundle"),
    filename: "bundle.js",
  },
};

解释下上面的两个变量,path.resolve __dirname

  1. path.resolve: 表示路径的拼接 它可以接受多个参数,依次表示所要进入的路径
path.resolve('foo/bar', '/tmp/file/', '..', 'a/../subfile')

相当于

$ cd foo/bar
$ cd /tmp/file/
$ cd ..
$ cd a/../subfile
$ pwd
path.resolve('/foo/bar', './baz')
/foo/bar/baz

path.resolve('/foo/bar', '/tmp/file/')
/tmp/file

path.resolve('wwwroot', 'static_files/png/', '../gif/image.gif')
如果当前目录是/home/myself/node,返回
/home/myself/node/wwwroot/static_files/gif/image.gif

用于将相对路径转为绝对路径。

  1. __dirname: 获得当前执行文件所在目录的完整目录名

所以 path.resolve(__dirname, "bundle"), 表示当前目录下的bundle文件夹

我们运行npx webpack后,编译后编译器输出了以下内容: image.png

其中的name:main,代表 webpack.config.js 中输入文件的一种形式,当我们定义单一入口时,entry就默认为main了。

module.exports = {
  entry: "./index.js",
  entry: {
    main: "index.js",
  }
};

mode

配置文件中有个mode属性,mode属性有两个值,development 和 production。二者的区别是生成后的打包文件是否会被压缩。development不会被压缩,而production会被压缩。

行内命令

除了通过webpack.config.js来配置启动项,我们在script内也可配置启动一些配置项来管理项目。具体内容可参考文档 webpack.docschina.org/api/cli/

loader

打包图片

我们所指的模块,不仅仅是js文件。less,css,jpg,png都可以称为一个模块。webpack只会打包以js文件,如果遇到.jpg,.jpeg结尾的文件,它就傻眼了。此时需要我们单独的配置config文件,告诉webpack如何去打包。

file-loader

file-loader可打包图片

 npm i file-loader -D
 
 const path = require("path");

module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpg|jpeg|png|gif)$/,
        use: "file-loader",
      },
    ],
  },
};

运行打包命令后,会发现bundle文件夹下多出了一个图片文件,这就是打包后的图片。我们console.log,输出了图片的文件名。 image.png

在webpack打包的时候,遇到了jpeg结尾的图片,webpack按照file-loader的打包规则,将图片移到了bundle文件夹下,然后把文件的地址返回给我们import的变量。

由此看出,loader是打包的方案。webpack不能识别非js后缀的模块,就通过loader让webpack识别出来。

我们也可指定打包后图片的名称,和图片打包的地址。

module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpg|jpeg|png|gif)$/,
        loader: "file-loader",
        options: {
          // 打包的格式,name原文件名,ext后缀名
          name: "[name].[ext]",
          // 打包在哪个文件夹下
          outputPath: "images/",
        },
      },
    ],
  },
};

url-loader

url-loader也具有和file-loader相似的功能,但url-loader打包出来的东西,和url-loader还是不同的。

image.png

由上图可以看到,图片没有被打包成一个文件,再去页面上发现可以看到这个img。

image.png

由此可见,url-loader打包图片,是将图片打包成base64字符串放在js里。base64的一个优点是节约了一次图片请求,但是base64非常的长,图片过于大的时候,会使js非常的大,影响页面的加载。url-loader提供了一种思路,合理的平衡了2者。当图片比较小时,转化为base64打包进js文件里,当图片过大时,就把image打包成一个图片放到bundle文件夹内。

image.png

limit为文件大小,webpack官网建议,8192B(8KB)以下的文件打包成base64,超过8192B的图片打包成单独的图片文件。

module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpg|jpeg|png|gif)$/,
        use: [
          {
            loader: "url-loader",
            options: {
              limit: 8192,
            },
          },
        ],
      },
    ],
  },
};

asset module(webpack5)

file-loader 和 url-loader 是webpack4的用法。在webpack5中,新增了asset module (资源模块),它使我们配置字体和图片时无需配置额外的loader。

module.exports = {
  output: {
    // 自定义文件名
    assetModuleFilename: "images/[name]_[hash][ext]",
  },
  module: {
    rules: [
      {
        test: /\.(jpg|jpeg|png|gif)$/,
        type: "asset",
        parser: {
          // 大小限制,webpack默认为8kb
          dataUrlCondition: {
            maxSize: 8 * 1024,
          },
        },
      },
    ],
  },
};

打包样式

css-loader

css-loader会对@importurl()进行处理,找到它们的引用关系,将他们合并为一个文件。它的功能有点类似于import和require

style-loader

在css-loader合并为一个css文件后,style-loader 将 style 标签插入到DOM中(head内)

image.png

less-loader

npm install less less-loader --save-dev

loader的解析是从右到左的,先将less解析为css,再挂载到DOM上。

npm i css-loader style-loader -D
npm i less less-loader --save-dev

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ["style-loader", "css-loader"],
      },
      {
        test: /\.less$/i,
        use: ["style-loader", "css-loader", "less-loader"],
      },
    ],
  },
};

postcss-loader

如下图所示,style中的css3特性,并没有兼容适配于各个浏览器的前缀,比如-webkit-,-moz-,-o-等,我们每个属性挨个写又太麻烦,使用postcss-loader中的autoprefixer插件,可帮助我们解析less时自动添加。

image.png

npm install --save-dev postcss-loader postcss
// 使用 autoprefixer 添加厂商前缀。
npm install --save-dev autoprefixer

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ["style-loader", "css-loader","postcss-loader"],
      },
      {
        test: /\.less$/i,
        use: ["style-loader", "css-loader", "postcss-loader","less-loader"],
      },
    ],
  },
};

遇到postcss-loader后,去postcss.config.js中寻找配置项

// postcss.config.js
module.exports = {
  plugins: ["autoprefixer"],
};

// package.json
"browserslist": [
  "defaults",
  "ie> 8"
]

package.json中定义了当前项目需要兼容的浏览器,postcss是根据browserslist中的浏览器进行适配的。如果browserslist中的浏览器都支持该属性,那么这个css属性不需要加上前缀。所以不加browserslist会导致autoprefixer不生效。

css-loader补充

前面我们知道css-loader是合并css文件的,但是在合并过程中还会遇到很多问题,下面我们一起来看看会有什么问题。

importLoaders(没生效)

importLoaders 是 css-loader的属性。webpack对于js中引入的less文件,会从右到左根据config中定义的顺序进行解析。但是对于less中引入less,被引入的less文件则不会按照config定义的规则解析,而是直接使用css-loader进行编译。

我们来看下

// index.less
/*
 * 在index.less中通过@import引入test.less,这时候test.less中的样式不会被postcss编译。
 */ 
@import './test.less';
span {
    color: pink;
    &.name {
        transform: translate(100px, 100px);
    }
}

// test.less
p {
    &.name {
        transform: translate(50px, 50px);
    }
}

image.png

image.png

所以css-loader提供了importLoaders属性来解决这个问题,

module(性能不好)

module设置为true后开启css模块化,任何项目中引入的less文件只属于当前模块自己拥有。

module.exports = {
  module: {
    rules: [
      {
        test: /\.less$/i,
        use: [
          "style-loader",
          {
            loader: "css-loader",
            options: {
              modules: true,
            },
          },
          "postcss-loader",
          "less-loader",
        ],
      },
    ],
  },
};

// 使用
import style from "./index.less";

let img = document.createElement("img");
img.setAttribute("src", pic1);
img.classList.add(style.img);

字体体验版

plugin

前面我们知道loader是帮助webpack打包非js文件的,plugin也是webpack中很重要的一个概念,它可以在打包的某个关键时刻帮助我们做一些事情。

html-webpack-plugin

每次打包后的文件是一个js文件,想让它展示在页面上需要手动在bundle文件夹下创建一个index.html,如果每次打包后都手动创建是非常不便利的,html-webpack-plugin帮助我们解决了这个问题。

npm install --save-dev html-webpack-plugin


const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  mode: "development",
  plugins: [new HtmlWebpackPlugin()],
};

run npm dev后会自动在bundle文件夹下创建一个index.html,并且将我们打包后的js引入到html里。

image.png

image.png

新打包成的html中虽然引入了js,但是没有root根结点,导致js中的元素无法挂载到dom节点上,html-webpack-plugin允许提前预设一个模版,打包后直接在bandle文件夹下创建这个模版,并且把js引入到html中。

const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  mode: "development",
  plugins: [
    // 定义模版文件
    new HtmlWebpackPlugin({
      template: "./index.html",
    }),
  ],
};

// index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>html 模版</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

clean-webpack-plugin

clean-webpack-plugin不是官网推荐的插件,它是一个三方的插件,它帮助我们每次build前都删除原先的目录,再创建一个新的目录,防止上次build后文件的残留。

npm install --save-dev clean-webpack-plugin

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");

module.exports = {
  // 使用CleanWebpackPlugin时必须要有output,否则plugin不知道上次打包的是哪个,无法执行清除
  output: {
    path: path.resolve(__dirname, "bundle"),
    filename: "bundle.js",
  },
  plugins: [
    new CleanWebpackPlugin(),
  ],
};

注:在webpack5中,在output中加一个clear:true属性即可替代clear-webpack-plugin的作用。

output

publicPath

output输出的是相对地址,但在企业级开发时我们的静态资源往往会被挂载到cdn上,这时候需要设定publicPath。publicPath为每个url添加前缀,当将资源托管到 CDN 时,publicPath可以设定为cdn地址。

module.exports = {
  output: {
    path: path.resolve(__dirname, "bundle"),
    filename: "bundle.js",
    publicPath: "https://cdn.com",
  },
};

image.png

clean

webpack5中,output中的clean属性替代了clean-webpack-plugin,无需再次引入plugin了。

module.exports = {
  output: {
    clean: true,
  },
};

source-map

运行打包后的代码时,如果出现了代码错误,在浏览器中只能定位到打包后的代码中出现错误的地方,而无法定位到打包前代码的错误位置,这使得我们在查找错误点时相当的麻烦。sourcemap可以对打包前后的文件进行映射。能找到转换后的代码所对应的转换前的位置。有了这种映射关系后,出错时,可以在控制台直接显示源代码中出错的位置。

现在webpack5运行时,不使用source-map属性也可以进行错误代码定位了。

module.exports = {
  devtool: "source-map",
};

inline-source-map 和 source-map

devtool的属性值有两个,source-map和inline-source-map,它们的功能是一样的。

使用source-map打包后,会生成一个.map文件,这个.map文件记录了打包后代码和打包前代码位置映射的关系。

image.png

当使用inline-source-map打包后,.map文件不再是一个单独的文件,而是将它以一个base64的方式写到了bundle.js文件的底部了。

image.png

不同前缀的差别

除了inline-前缀,还有很多其他的前缀,大致简单介绍一下。

  • cheap 不加cheap会使bug定位到具体字符,而cheap只帮我们定位到某行。但使用cheap会使代码编译的性能较好
  • module 加了module可定位三方依赖的错误,不加module只可定位业务代码的错误
  • eval 会只映射出错的地方

具体他们之间的差异也可查阅官方文档 webpack.docschina.org/configurati…

source-map不只应用在开发环境,项目部署到线上我们也想暴露一些问题的话,也需要配置source-map。

development devetool: cheap-module-eval-source-map
production : cheap-module-source-map

webpack-dev-server

我们每次改变业务代码后,都需要手动的npm run dev,再打开index.html。我们希望每次改完源代码,bundle目录下的代码自动更新。一种有3种方式可以实现。

watch

使用watch后可监听到源代码的改变,webpack重新打包,我们只需要手动刷新一下即可。

  "scripts": {
    "watch": "webpack --watch",
  },

dev-server

watch只能监听变化并且重新打包,webpack-dev-server提供了更便捷的功能。

  1. 打包后能自动唤起html
  2. 启动一个服务器去展示html
  3. 自动刷新浏览器。
npm install --save-dev webpack-dev-server

"scripts": {
  "start": "webpack serve"
},

module.exports = {
  devServer: {
    // 告知 dev server,从什么位置查找文件
    static: "./bundle",
    // 服务启动后打开浏览器
    open: true,
    // 监听的端口号
    port: 8000,
  },
}

更多配置可查看文档 webpack.docschina.org/configurati…

为什么要启动服务展示页面

我们在本地也可以展示html,但发送http请求的时候,必须通过服务器去发送请求。所以我们必须要使用webpack-dev-server来启动一个服务。

proxy(待整理)

打包

我们使用webpack-dev-server打包后,发现没有在目录下生成一个dist文件夹。webpack-dev-server将文件生成在内存里,这样可以有效的提升打包速度

手写server(待学习)

我们可以通过自己写一个服务,来模拟webpack-dev-server的功能。但是这块会涉及到node部分,在node中使用webpack。后面我会单独学习一下,再写一篇相关文章。

hot module replacement(HMR)

热模块替换是webpack最有用的功能之一。它允许在运行时更新所有类型的模块,而无需完全刷新。

我们先来看个例子。

// index.js
import addItem from "./test.js";
import "./index.less";

let dom = document.getElementById("root");
let btn = document.createElement("button");
btn.innerHTML = "click";
btn.onclick = function () {
  addItem();
};
dom.appendChild(btn);

--------------------------------------

// index.less
.item:nth-of-type(odd) {
    background-color: yellow;
}

--------------------------------------

// test.js
export default function () {
  let dom = document.getElementById("root");
  let item = document.createElement("div");
  item.setAttribute("class", "item");
  item.innerHTML = "item";
  dom.appendChild(item);
}

image.png

连续点击多次click,item被不断累加,当我们在代码中更改颜色后,返回页面时代码没有被刷新(item没有被清零),只是颜色改变了。这就是热模块更新。不重新刷新页面,只局部更新。

下面在webpack中配置下热模块更新。
hot有2个属性,true/only。

  • only:如果没有热更新成功,不重新刷新页面
  • true:如果没有热更新成功,则刷新页面
const webpack = require("webpack");

module.exports = {
  devServer: {
    hot:'only' 
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin(),
  ],
}

这样配置完即可使用热模块更新了,less/css 的热更新比较简单, 因为style-loader内部帮助我们进行了module.hot.accept的操作。如果是js文件,无需经过loader的转化,则需要自己进行module.hot.accept的操作。

很多loader为我们编写了module.hot.accept,开发时我们不需要在考虑热更新问题,直接写业务代码即可。

下面我们再来看组例子,下面是需要我们自己写module.hot.accept的场景。

// index.js

import test from "./test.js";
let dom = document.getElementById("root");
let num = document.createElement("div");
num.innerHTML = 0;
num.onclick = function () {
  num.innerHTML = Number(num.innerHTML) + 1;
};
dom.appendChild(num);

test();
--------------------------------------
// test.js
export default function () {
  let dom = document.getElementById("root");
  let item = document.createElement("div");
  item.innerHTML = 400;
  item.onclick = function () {
    item.innerHTML = Number(item.innerHTML) + 1;
  };
  dom.appendChild(item);
}

image.png

每次在代码中改变某个值,页面不更新为新值,也不刷新页面。这是因为我们没有使用到module.hot.accept,下面我们来改进下,就可以热更新了。

// index.js

import test from "./test.js";

let dom = document.getElementById("root");
let num = document.createElement("div");
num.innerHTML = 0;
num.onclick = function () {
  num.innerHTML = Number(num.innerHTML) + 1;
};
dom.appendChild(num);

let ele = test();
// 热更新,每当test.js中代码改变了,则重新渲染test.js,渲染前先移除dom
if (module.hot) {
  module.hot.accept("./test.js", function () {
    dom.removeChild(ele);
    ele = test();
  });
}

--------------------------------------

// test.js

export default function () {
  let dom = document.getElementById("root");
  let item = document.createElement("div");
  item.innerHTML = 200;
  item.onclick = function () {
    item.innerHTML = Number(item.innerHTML) + 1;
  };
  dom.appendChild(item);
  return item;
}

babel处理es6语法

@babel/preset-env

我们写段es6语法,经过webpack编译一下,看下编译后的内容。

let arr = [
  new Promise(() => {
    console.log("promise");
  }),
];

arr.map((item) => {
  return item;
});

image.png

打开chrome浏览器可以执行编译后的代码,因为chrome浏览器更新的比较及时,但有很多浏览器比如IE,是不认识ES6语法的。为了兼容更多的浏览器,我们可以使用babel将ES6语法变为ES5的语法。

npm install --save-dev babel-loader @babel/core
npm install @babel/preset-env --save-dev

// webpack.config.js

module.exports = {
  module: {
    rules: [
      {
        test: /\.m?js$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ["@babel/preset-env"],
          },
        },
      },
    ],
  },
};

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

babel-loader是babel和js做通讯的桥梁,但是它并不会将js中的ES6语法转换为ES5语法,语法转换还需要@babel/preset-env。具体babel转换的规则,需要我们定义一个babel.config.json去定义babel转换的规则。

babel会不断升级,可查看官方指南进行配置 www.babeljs.cn/setup#insta…

查看下经过@babel/preset-env编译的内容,

image.png

@babel/polyfill

ES6的语法确实被编译了,箭头函数改为了function,但是它并没有编译ES6的api,比如Promise,map函数,Array.from,Object.assign。所以我们需要@babel/polyfill来处理ES6的api,但根据babel官网的说明,@babel/polyfill在Babel 7.4.0后被废弃,我们需要使用新的依赖。

yarn add core-js regenerator-runtime


module.exports = {
  module: {
    rules: [
      {
        test: /\.m?js$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            presets: [
              [
                "@babel/preset-env",
                {
                 // 并不需要兼容全部低版本的代码
                 // 根据业务代码的需要有选择性的添加
                  useBuiltIns: "usage",
                 // 需要兼容的浏览器
                  targets: {
                    chrome: "58",
                    ie: "11",
                  },
                  // corejs必须与useBuiltIns: "usage"一起使用,3表示core-js版本号
                  corejs: 3,
                },
              ],
            ],
          },
        },
      },
    ],
  },
};

我也是根据官网,并且参考一些博主的文章后配置的。

再来看下配置后编译的文件,可以看到,Promise也被编译了。

image.png

引入@babel/polyfill之前打包后的文件时91KB image.png

经过@babel/polyfill编译后的文件是 316KB,多出来的大小就是为了兼容底版本,对Promise,map等ES6 api的实现,把这些实现加到bundle.js中,所以打包后的文件就变大了。 image.png

我们的配置项中的useBuiltIns: "usage",代表可根据业务代码来编译内容。我们去掉promise的使用的话,可看到bundle.js的文件一下子变小了。此处证明我们的useBuiltIns: "usage"是生效的了。

我们不断编写babel文件会使config的代码很冗长,我们可以单独写一个babel的config,把useoption选项移动到babel.config.js,编译的时候会自动检索babel.config.js

// webpack.config.js

module.exports = {
  module: {
    rules: [
      {
        test: /\.m?js$/,
        exclude: /node_modules/,
        use: ["babel-loader"],
      },
    ],
  },
};

// babel.config.js

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "usage",

        "targets": {
          "chrome": "58",
          "ie": "11"
        },
        "corejs": 3
      }
    ]
  ]
}

babel处理react jsx语法

Babel 能够转换 JSX 语法

// 下载react
npm i react react-dom

// 编写jsx语法
import React from "react";
import ReactDOM from "react-dom";

ReactDOM.render(<div>hello jsx</div>, document.getElementById("root"));

// 下载转化jsx的babel
npm install --save-dev @babel/preset-react

// 配置babel,babel.config.json
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "usage",

        "targets": {
          "chrome": "58",
          "ie": "11"
        },
        "corejs": 3
      }
    ],
    "@babel/preset-react"
  ]
}

babel的转化顺序也是从右到左,先讲react代码转换为js,再把ES6的代码转为ES5。

结尾

以上就是搭建一个webpack的所有内容了,这只是一个简单的webpack demo,并不涉及webpack的高阶特性和优化。想要了解webpack还是要学会自己读文档,然后跟着文档做一遍,相信做过一次的朋友一定会加深对webpack的理解。

本文的github地址,有需要的同学可以自取代码。 github.com/orangemiaos…