All articles

Webpack功能特性与工作原理

Sep 03, 2022

最近阅读《Webpack 入门、进阶与调优》所给的一些总结,主要是 Webpack 一些功能特征与 Webpack 的调优,分为两篇博客。

  • 这一篇是Webpack 功能特性与工作原理
  • 下一篇是Webpack 打包优化以及开发环境优化

主要是做一个记录和自己的一些思考与拓展,方便之后回顾,更多细节内容还是推荐阅读这本书。

简介

Webpack 是什么

关于它的介绍网络上铺天盖地,这里简单说一下,Webpack 是一个开源的前端打包工具。Webpack 提供了前端开发缺乏的模块化开发方式,将各种静态资源视为模块,并从它生成优化过的代码。

为什么需要 Webpack

随着项目业务的增加,Web应用会变得很庞大,因此需要借助一些工作,否则人工去维护代码成本太高,并且也难以维护。Webpack可以帮我们处理好不同类型模块之间的依赖关系,并且可以有效的减少资源提及,提高渲染速度。

就我而言,其实主要是之前只是了解webpack,没有系统的去学习过,并且目前手上的产品体积很大,系统中已经存在大量的webpack配置,学习了方便之后可以进行维护。

如何使用 Webpack

安装

首先需要安装webpack,有全局安装和本地安装,推荐使用本地安装,这样好在不同项目管理不同版本,也防止污染其他人的输出结果。

npm install webpack webpack-cli --save-dev

webpack是核心模块、webpack-cli是命令行工具,安装之后可以通过命令npx webpack -v 以及 npx webpack-cli -v查看版本号验证是否安装成功。这里无法直接使用webpack命令因为没有安装在全局。

使用

这里简单例子说明一下

// index.js
import content from "./content.js";
document.write(content);
// content.js
export default "Hello Webpack!";

直接使用命令行进行打包

npx webpack --entry=./index.js --output-filename=bundle.js --mode=development
  • entry是打包资源的入口
  • output-filename是输出资源名(默认输出到dist)。
  • mode是打包模式(developmentproductionnone)。

可以看出每次都需要输入上面shell命令,很冗余繁琐,因此可以使用npm scripts进行打包,在package.json中添加脚本命令。

"scripts": {
  "build": "webpack --entry=./index.js --output-filename=bundle.js --mode=development"
}

可以使用通过npm命令:npm run build重新打包。甚至,可以使用更加利于维护的webpack.config.js,如下。

const path = require("path");
const htmlPlugin = require("html-webpack-plugin");

module.exports = {
  entry: "./src/index.js",
  output: { filename: "bundle.js" },
  mode: "development",
  plugins: [
    new htmlPlugin({
      title: path.basename(__dirname),
    }),
  ],
  devServer: {
    publicPath: "/dist/",
    port: 3000,
  },
};
"scripts": {
  "build": "webpack"
}

这样在module.exports中导出对象通过key-value接受打包时候的参数。

webpack-dev-server

  • webpack进行模块打包,并处理打包结果的资源请求。
  • 作为WebServer处理静态资源。
  • 数据更新后,可以自动刷新(live-loading)页面。
"scripts": {
  "dev": "webpack-dev-server --open-page \"dist/index.html\"",
  "build": "webpack"
}

在上面的webpack.config.js中已经体现出来,devServer就是webpack-dev-server的相关配置。通过npm run dev可以直接启动服务。

模块

CommonJS

CommonJS中规定每一个文件是一个模块。直接用script引用JS与其最大的区别就是前者顶层作用域是全局作用域,而CommonJS会形成自己的作用域,所有的变量只有自己能访问。

导出

通过module.exports可以导出模块的内容,其指定了该模块要对外暴露哪些内容,也就是说导出的是module.exports这个对象。

module.exports = {
  name: "calculator",
  add: function (a, b) {
    return a + b;
  },
};

也可以直接用exports

exports.name = "calculator";
exports.add = function (a, b) {
  return a + b;
};

这是因为在内部存在机制将exports指向module.exports,可以理解为在CommonJS模块的首部默认添加了如下代码:

var module = {
  exports: {},
  // 判断是否被加载
  loaded: false,
};
var exports = module.exports;

这样就很好理解上面的两种导出方式了,由于有这种机制,因此不能直接给exports赋值,否则会失效。

exports = {
  name: "calculator",
};

此时exports所致的对象已发生改变,原来的module.exports仍然是空对象。并且在module.exportsexports之后的代码也是会执行的,其不像return后面的语句不会执行。

导入

使用require进行模版的导入。

// calculator.js
console.log("running calculator.js");
module.exports = {
  name: "calculator",
  add: function (a, b) {
    return a + b;
  },
};
// index.js
const add = require("./calculator.js").add;
const sum = add(2, 3);
console.log("sum:", sum);
const moduleName =
  require("./calculator.js").name;
console.log("end");
// terminal
// running calculator.js
// sum: 5
// end

require的模块,第一次被加载,之后遇到,如果被加载过,不会再执行,而是直接导出上次执行后的结果。

ES6 Module

ES6 Module中规定每一个文件是一个模块,都有自己的作用域。其会自动采用严格模式,ES5需要加上"use strict"来控制严格模式,ES6 Module无论是否有"use strict",都会采用严格模式。

导出

其导出方式主要分为两种。

  • 命名导出。
  • 默认导出。

在命名导出中,可以有两种方式。

// 1
export const name = "calculator";
export const add = function (a, b) {
  return a + b;
};
// 2
const name = "calculator";
const add = function (a, b) {
  return a + b;
};
export { name, add as _getSum };

可以分开导出,也可以定义完之后,一起导出,并且可以使用as关键字对变量进行重命名。

在默认导出中,只有一种导出方式。可以理解为输出了一个名为default的变量,并且其可以导出字符串class匿名函数

export default {
  name: "calculator",
  add: function (a, b) {
    return a + b;
  },
};

导入

ES6 Module主要是你用的是import语句进行导入模块。

// calculator.js
const name = "calculator";
const add = function (a, b) {
  return a + b;
};
export { name, add };
// index.js
import {
  name,
  add as _getSum,
} from "./calculator.js";
add(2, 3);

需要对应上export中的内容,并且也可以使用as关键字对变量进行重命名。

导入多个变量,可以直接整体导入。

import * as calculator from "./calculator.js";

在默认导出时,import后面直接跟自定义的变量名。以及混合起来都是可以的,但是React必须在大括号前。

import calculator from "./calculator.js";
import React, { Component } from "react";

CommonJS 与 ES6 Module 区别

第一个区别是“静态”和“动态”,CommonJS 是动态的,ES6 Module 是静态的。动态表示模块依赖关系建立发生在代码运行阶段;静态则是发生在代码编译阶段

也就是说在CommonJS中,moduleA加载moduleB,会去执行moduleB中的代码,遇到module.exports之后会将require作为返回值进行返回。require支持传入一个表达式,甚至可以用 if 来判断是否加载这个模块,在执行之前无法判断依赖关系,因此建立是发生在运行阶段。

ES6 Module而言其导入、导出都是声明式,不支持导入路径是表达式,因此在编译期间就建立了依赖关系。其根据这些特性可以进行死代码检测和排查,模块变量类型的检查。

**第二个区别是值拷贝和动态映射,CommonJS 是值拷贝,而 ES6 Module 是动态映射。**简单来说就是深拷贝与浅拷贝的关系,CommonJS引入进来之后就不受原来控制,而ES6 Module还是映射到原来的模块。

第三个区别是两者对循环依赖的处理方式不同。

// CommonJS
// foo.js
const bar = require("./bar.js");
console.log("value of bar:", bar);
module.exports = "This is foo.js";

// bar.js
const foo = require("./foo.js");
console.log("value of foo:", bar);
module.exports = "This is bar.js";

// index.js
require("./foo.js");
// terminal
// value of foo: {}
// value of bar: This is bar.js

执行index.js -> foo.js -> bar.js -> 这里由于已经 require(‘./foo.js’),因此不会交回 foo,直接获取默认的空对象)-> foo.js -> index.js。这里后面会提到,require之后,会将module对象放入到installedModules一个数组中,之后再次遇到直接取里面的值。

// ES6 Module
// foo.js
import bar from './bar.js';
console.log('value of bar:', bar);
export default 'This is foo.js';

// bar.js
import foo from './foo.js';
console.log('value of foo:', foo);
export default 'This is bar.js';

// index.js
import foo from './foo.js';
// terminal
// value of foo: undefined
// value of bar: This is bar.js

这里和CommonJS逻辑一样,不过这里不是空对象,而是undefined。但是由于ES6 Module具有动态映射,因此可以利用这个特性来支持循环依赖,可以思考一下如何完成?

打包原理

其实还有诸如AMDUMD等标准,但是目前使用的已经不多。现在来深入理解一下webpack的打包原理,还是刚刚的实例代码,将其打包之后可以生产如下的代码(bundle.js),下面给出的代码有些内容被省略了。

/**
 * Immediate execution of anonymous functions
 * @param {*} modules All dependencies in the project will be stored in `key-value` form.
 * @returns
 */
(function (modules) {
  // The module cache: executed when each module is loaded for the first time and its exported values are stored in this object.
  var installedModules = {};
  // The require function
  function __webpack_require__(moduleId) {
    // Check if module is in cache
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    // Create a new module (and put it into the cache)
    var module = (installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {},
    });
    // Execute the module function
    // The corresponding key function is executed, and the result is mounted on `module.export` after execution
    modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      __webpack_require__
    );
    // Flag the module as loaded
    module.l = true;
    // Return the exports of the module
    return module.exports;
  }

  // Other codes are included here, includes __webpack_require__.(m/c/d/r/n/d/o/p)

  // Load entry module and return exports
  return __webpack_require__(
    (__webpack_require__.s = 0)
  );
})({
  0: function (
    module,
    exports,
    __webpack_require__
  ) {
    module.exports = __webpack_require__("3qiv");
  },
  "3qiv": function (
    module,
    exports,
    __webpack_require__
  ) {
    // index.js content
  },
  jkzz: function (module, exports) {
    // calculator.js content
  },
});

在生成的bundle.js

  • 会形成一个立即执行匿名函数。前面提到的installedModules会作为模块的缓存。

  • 然后实现require。判断引入的模块是否存在与缓存中,如果存在直接获取缓存中的exports,如果不存在会创建一个exports空对象放入到缓存中,相应的键值函数被执行,执行后的结果被挂载到module.export上,更新moduleflag标志标识已经被加载了,最终返回module.exports

  • 最后执行入口模块的加载。

传入的module对象全都是以key-value形式储存了所有被打包的模块。通过传入__webpack_require__.s = 0,表示入口模块的key,执行加载模块,当执行到module.exports记录下模块的导出值,而遇到__webpack_require__,则会交出执行权,去执行函数体内加载其他模块的逻辑(实际上是一个递归的过程),所有加载完回到入口模块,运行结束。

基础配置

介绍基础配置之前,先看看chunkentrybundle的关系,如下图所示。

chunk表示代码块,在一个工程中可能会有一个或者多个的chunk,它包含了很多模块的依赖关系。

注意在某些特殊情况下,一个入口可能产生多个chunk和多个bundle

入口

webpack通过contextentry两者共同决定入口文件的路径。定义出chunk name,如果一个工程只有一个入口,那么其默认的chunk namemain;如果有多个入口,需要每个入口都定义一个chunk name

context

只能为字符串。资源入口路径的前缀,需要采用绝对路径。下面的配置信息入口都是<root>/src/scripts/index.js。主要是为了让entry写的更加简洁。

module.exports = {
  context: path.join(__dirname, "./src"),
  entry: "./scripts/index.js",
};
module.exports = {
  context: path.join(__dirname, "./src/scripts"),
  entry: "./index.js",
};

entry

可以为字符串、数组、对象、函数。

// String
module.exports = {
  entry: './src/index.js';
};
// Array
module.exports = {
  entry: ['babel-polyfill', './src/index.js'];
};
// Object
module.exports = {
  entry: {
    index: './src/index.js',
    lib: './src/lib.js'
  }
};
// Function
module.exports = {
  entry: () => new Promise((resolve) => {
    setTimeout(() => {
      resolve('./src/index.js');
    }, 1000);
  })
};
  • 采用数组时,webpack会将最后一个元素作为实际的入口路径。
  • 采用对象时,key对应chunk namevalue对呀入口路径。
  • 采用函数时,可以添加一些逻辑来获取工程中的入口,支持返回Promise进行异步操作。

vendor

webpack默认配置时,当bundle超过250kB时,会发出过大警告,此时代码一旦更新,都需要重新下载资源文件。

vendor则是代表把工程中使用的库、框架集中打包所产生的bundle

module.exports = {
  entry: {
    app: "./src/app.js",
    vendor: [
      "react",
      "react-dom",
      "react-router",
    ],
  },
};

注意,这里没有设置vendor的入口路径,但是可以采用optimization.splitChunkswebpack4之后),将appvendor这两个chunk中的公共模块提取出来。app.js产生的bundle只包含业务模块,其依赖的第三方模块会抽离成一个新的bundle

出口

所有与出口相关配置都集中在output对象。而output中有数十个配置项,常用的能覆盖大多数场景(filenamepathpublicPath)。

filename

其作用是控制输出资源的文件名,形式为字符串。在许多场景下,需要每个bundle产生不同的名字,其配置项模版变量有[name][hash][chunkhash][id][query]

这些变量主要的作用是区分chunk和控制客户端缓存,表中的[hash][chunhash]都与chunk内容直接相关。

module.exports = {
  output: {
    filename: "[name]@[chunkhash].js",
  },
};

path

指定资源输出的位置,必须为绝对路径。

const path = require("path");
module.exports = {
  output: {
    filename: "[name]@[chunkhash].js",
    path: path.join(__dirname, "dist"),
  },
};

这里将打包文件输出到dist目录下,在webpack4之前默认会生成到根目录,webpack4之后会默认生成到dist目录。

publicPath

  • path指定资源的输出位置。
  • publicPath知道资源的请求位置。

比如当前地址为https://example.com/app/index.html,异步加载的资源名为 0.chunk.js

publicPath: ""; // https://example.com/app/0.chunk.js
publicPath: "./js"; // https://example.com/app/js/0.chunk.js
publicPath: "../assets/"; // https://example.com/assets/0.chunk.js

webpack-dev-server中也有一个publicPath,这里和webpack中的含义不同,这里制定静态资源服务的路径。

loader

webpack只认识js代码,在项目中,还有大量的css、图片、字体等需要进行打包,而这些就需要各种loader来进行输出成webpack可以认识的内容。每一个loader都是一个函数:

$$ output = loader(input) $$

例如使用babel-loaderES6+的代码转换成ES5时,ES5 = babel-loader(ES6+),并且其是可以链式操作的,也就是说上一个的输出可以是下一个的输入。

loader的配置,则是控制这些内容的处理过程,其配置在module里面,下面将介绍常见的相关配置。

rules

const path = require("path");
const htmlPlugin = require("html-webpack-plugin");

module.exports = {
  entry: "./src/index.js",
  output: {
    filename: "[name].js",
  },
  mode: "development",
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          "style-loader",
          {
            loader: "css-loader",
            options: {
              // css-loader config
            },
          },
        ],
      },
    ],
  },
  plugins: [
    new htmlPlugin({
      title: path.basename(__dirname),
    }),
  ],
  devServer: {
    publicPath: "/dist/",
    port: 3000,
  },
};

这里直接用一个实例来说明了如何配置rulesmodule.rules代表的是模块的处理规则,其是一个对象数组,每一个对象都是处理不同的文件,这里以css为例,test代表去正则匹配符合这条规则的文件。use则是采用哪些loader进行处理,loader的处理顺序在数组中是从后往前。这里为了说明不同情况,把css-loader写成了对象形式,目的是说明可以通过options传入相关配置项。

exclude 与 include

从这个名称可以看出其作用,就是排除或包含制定目录下的模块,接收的是字符串(文件的绝对路径)或者是正则表达式。

rules: [
  {
    test: /\.css$/,
    use: ["style-loader", "css-loader"],
    exclude: /src\/lib/,
    include: /src/,
  },
];

以上面这个简单例子进行说明,排除/src/lib/目录下的css文件,只进行打包其他/src/目录下的文件。并且由于exclude相比于include来说有更高的优先级,include无法覆盖掉exclude

resource 与 issuer

这两个是用来更加精确地确定模块规则的作用范围。前面所说的testexcludeinclude本质上属于对resource也就是被加载着对配置。

rules: [
  {
    use: ["style-loader", "css-loader"],
    resource: {
      test: "/.css$/",
      exclude: /node_modules/,
    },
    issuer: {
      test: "/.js$/",
      exclude: /node_modules/,
    },
  },
];

表示只允许除了node_modules目录以外的js文件引入除了node_modules目录以外的css文件,并且采用上方的loader形式进行打包。

enforce

制定loader的种类,只接收“pre”或“post”。

rules: [
  {
    test: /\.js/,
    enforce: "pre",
    use: "eslint-loader",
  },
];

表示它将在所有loader之前执行,保证被检测的代码没有被其他loader更改过。

小结

最后关于loader还有几点想说明的,对于不同的资源,在社区中几乎都给出了相关的loader进行解决,这里便不一一展示,可以在不同loader的文档中查看实例代码。上面主要是用css-loader进行演示,其他的还有诸如babel-loaderfile-loaderts-loadervue-loader,甚至在不满足需求的情况下,可以自己写一个loader,因为前面提到了可以通过loader里面的options获取配置项。

antcao.me © 2022-PRESENT

: 0x9aB9C...7ee7d