webpack核心概念入门

572 阅读13分钟

从这一章开始,我们来介绍下 webpack 核心概念及使用,为后面工程化改造打下基础。

webpack.config.js 配置文件

webpack 是可配置的模块打包工具,可以通过修改 webpack 的配置文件(webpack.config.js)来对 webpack 进行配置,webpack 的配置文件是遵循 Node.js 的 Commonjs 模块规范的。

简单的 webpack.config.js 示例

const path = require('path');

module.exports = {
    mode: 'development',
    entry: './src/index.js',
    devtool: 'source-map',
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: 'bundle.js'
    }
};

上面示例中,使用 Commonjs 的require引入 Node.js 内置的path模块,然后通过module.exports将 Webpack 的配置导出。

webpack 的配置是一个 Node.js 模块,所以并不只是 JSON 对象。

webpack 常见名词解释

当我们谈论 Webpack 的时候,往往会提到下面的名词:

参数说明
entry项目入口
output项目输出
module开发中每一个文件都可以看做 module,模块不局限于 js,也包含 css、图片等
chunk代码块,一个 chunk 可以由多个模块组成,也可以理解为打包的中间产物
loader模块的处理器,对模块进行转换处理
plugin扩展插件,插件可以处理 chunk,也可以对最后的打包结果进行处理,可以完成 loader 完不成的任务
bundle最终打包完成的文件,一般就是和 chunk 一一对应的关系,bundle 就是对 chunk 进行便意压缩打包等处理后的产出

入口(entry)和输出(output)

从 webpack 的官网介绍中可以知道,webpack 是一个模块打包工具,能够从一个需要处理的 JavaScript 文件开始,构建一个依赖关系图(dependency graph),该图映射到了项目中每个模块,然后将这个依赖关系图输出到一个或者多个 bundle 中。

从上面文字的认识,可以轻易的得到 webpack 的两个核心概念:entryoutput,即入口和输出,webpack 是从指定的入口文件(entry.js)开始,经过加工处理,最终按照output设定输出固定内容的 bundle.js;而这个加工处理的过程,就用到了loaderplugin两个工具;loader是源代码的处理器,plugin解决的是 loader处理不了的事情,我们将会在后面的文章中介绍loaderplugin

context

context 即项目打包的相对路径上下文,如果指定了context="/User/webpack-test",那么我们设置的entry和output的相对路径都是相对于/User/webpack-test的,包括在 JavaScript 中引入模块也是从这个路径开始的。由于context的作用,决定了context值必须是一个绝对路径。

// webpack.config.js
module.exports = {
    context: '/User/webpack-test'
};

在实际开发中 context 一般不需要配置,不配置则默认为process.cwd()即工作目录。

entry 入口

webpack 的entry支持多种类型,包括字符串、对象、数组。从作用上来说,包括了单文件入口多文件入口两种方式。

单文件入口

module.exports = {
    entry: './src/index.js'
};
// 或者使用对象方式
module.exports = {
    entry: {
        main: './src/index.js'
    }
};

多文件入口 多文件入口是使用对象语法来通过支持多个entry,多文件入口的对象语法相对于单文件入口,具有较高的灵活性,例如多页应用。

module.exports = {
    entry: {
        home: './src/home.js',
        search: './src/search.js',
        list: './src/list.js'
    }
};

上面的语法将entry分成了 3 个独立的入口文件,这样会打包出来三个对应的 bundle。

output 输出

webpack 的output是指定了entry对应文件编译打包后的输出 bundle。output的常用属性是:

  • path:此选项制定了输出的 bundle 存放的路径,比如dist、output等
  • filename:这个是 bundle 的名称
  • publicPath:指定了一个在浏览器中被引用的 URL 地址。

一个 webpack 的配置,可以包含多个entry,但是只能有一个output。对于不同的entry可以通过output.filename占位符语法来区分,比如:

module.exports = {
    entry: {
        home: './src/home.js',
        search: './src/home.js',
        list: './src/list.js'
    },
    output: {
        filename: '[name].js',
        path: __dirname + '/dist'
    }
};

其中[name]就是占位符,它对应的是entrykeyhomesearchlist),所以最终输出结果是:

./src/home.js -> dist/home.js
./src/search.js -> dist/search.js
./src/list.js -> dist/list.js

占位符

我将 webpack 目前支持的占位符列出来:

占位符含义
[hash]模块标识符的 hash
[chunkhash]chunk 内容的 hash
[name]模块名称
[id]模块标识符

[hash] 和 [chunkhash] 的长度可以使用 [hash:16](默认为 20)来指定。或者,通过指定 output.hashDigestLength 在全局配置长度,那么他们之间有什么区别吗?

  • [hash]:是整个项目的 hash 值,其根据每次编译内容计算得到,每次编译之后都会生成新的 hash,即修改任何文件都会导致所有文件的 hash 发生改变;在一个项目中虽然入口不同,但是 hash 是相同的;hash 无法实现前端静态资源在浏览器上长缓存,这时候应该使用 chunkhash;
  • [chunkhash]:根据不同的入口文件(entry)进行依赖文件解析,构建对应的 chunk,生成相应的 hash;只要组成 entry 的模块文件没有变化,则对应的 hash 也是不变的,所以一般项目优化时,会将公共库代码拆分到一起,因为公共库代码变动较少的,使用 chunkhash 可以发挥最长缓存的作用;
  • [contenthash]:使用 chunkhash 存在一个问题,当在一个 JS 文件中引入了 CSS 文件,编译后它们的 hash 是相同的。而且,只要 JS 文件内容发生改变,与其关联的 CSS 文件 hash 也会改变,针对这种情况,可以把 CSS 从 JS 中使用mini-css-extract-pluginextract-text-webpack-plugin抽离出来并使用 contenthash。

占位符是可以组合使用的,例如[name]-[hash:8]

output.publicPath

对于使用<script> 和 <link>标签时,当文件路径不同于他们的本地磁盘路径(由output.path指定)时,output.publicPath被用来作为src或者link指向该文件。这种做法在需要将静态文件放在不同的域名或者 CDN 上面的时候是很有用的。

module.exports = {
    output: {
        path: '/home/dist/',
        publicPath: 'http://test.com/assets/'
    }
};

则输出:

<head>
    <link href="http://test.com//assets/logo.png" />
</head>

output.library

如果我们打包的目的是生成一个供别人使用的库,那么可以使用output.library来指定库的名称,库的名称支持占位符和普通字符串:

module.exports = {
    output: {
        library: 'myLib' // '[name]'
    }
};

output.libraryTarget

使用output.library 确定了库的名称之后,还可以使用output.libraryTarget指定库打包出来的规范,output.libraryTarget取值范围为:var、assign、this、window、global、commonjs、commonjs2、commonjs-module、amd、umd、umd2、jsonp,默认是var。这些值会导致最后用户的引用方式的不同

// window 模式
{
    output: {
        library: 'myLib',
        filename: 'window.js',
        libraryTarget: 'window'
    }
}

window["myLib"] = (function(modules) {})({
    './src/index.js': function(module, exports) {}
});

// commonjs 模式
{
    output: {
        library: 'myLib',
        filename: 'commonjs.js',
        libraryTarget: 'commonjs'
    }
}
exports["myLib"] = (function(modules) {})({
    './src/index.js': function(module, exports) {}
});

// umd 模式
{
    output: {
        library: 'myLib',
        filename: 'umd.js',
        libraryTarget: 'umd'
    }
}
(function webpackUniversalModuleDefinition(root, factory) {
    if (typeof exports === 'object' && typeof module === 'object') module.exports = factory();
    else if (typeof define === 'function' && define.amd) define([], factory);
    else if (typeof exports === 'object') exports['myLib'] = factory();
    else root['myLib'] = factory();
})(window, function() {
    return (function(modules) {})({
        './src/index.js': function(module, exports) {}
    });
});

我们一般使用umd规范,它兼容3种写法:cjs,amd,global(在浏览器模式下global就是window)

webpack暂时只能支持导出 cjs 或 更往前兼容的包(umd),不支持导出es6模块规范。所以,第三方库我们一般使用rollup进行打包,vue、react等库都是使用rollup进行打包的。

webpack打包后的文件会注入很多代码,这是为什么呢?

因为webpack比rollup早出2年,诞生在esm标准出来前,commonjs出来后,那个时候的包都是采用commonjs模块规范写的。

关于webpack的代码注入问题,是因为浏览器不支持cjs,所以webpack要去自己实现require和module.exports方法,才有很多代码注入,因为webpack自己要实现polyfill。相当于,webpack把使用cjs模块的代码转化为浏览器能识别的代码,这样才能跑在浏览器中。

这么多年了,甚至到现在2022年,浏览器为什么不支持cjs?

cjs是同步的,运行时的,node环境用cjs,node本身运行在服务器,无需等待网络握手,所以同步处理是很快的。浏览器是客户端,访问的是服务端资源,中间需要等待网络握手,可能会很慢,所以不能同步的 卡在那里等服务器返回的,体验太差。

所以,我们平时开发nodejs后台代码是不需要经过打包这一步的,代码运行时直接通过require来同步加载模块,因为代码都在服务器上,所以不会有延时。

相反,前端项目都需要经过打包,把很多模块打包成一个或几个大的bundle,而不是在运行的时候才去加载模块,而模块都在服务器上,会产生延时,体验很差。

后续出来esm后,webpack为了兼容以前发在npm上的老包(并且当时心还不够决绝,导致这种丑结构的包越来越多,以后就更不可能改这种“丑结构了”),所以保留这个IIFE的结构和代码注入,导致现在看webpack打包的产物,乍看结构比较乱且有很多的代码注入,自己写的代码都找不到。

externals

externals配置项用于去除输出的打包文件中依赖的某些第三方 js 模块(例如 jquery,vue 等等),减小打包文件的体积。该功能通常在开发自定义 js 库(library)的时候用到,用于去除自定义 js 库依赖的其他第三方 js 模块。这些被依赖的模块应该由使用者提供,而不应该包含在 js 库文件中。例如开发一个 jQuery 插件或者 Vue 扩展,不需要把 jQuery 和 Vue 打包进我们的 bundle,引入库的方式应该交给使用者。

所以,这里就有个重要的问题,使用者应该怎么提供这些被依赖的模块给我们的 js 库(library)使用呢?这就要看我们的 js 库的导出方式是什么,以及使用者采用什么样的方式使用我们的库。例如:

js library 导出方式output.libraryTarget使用者引入方式使用者提供给被依赖模块的方式
默认的导出方式output.libraryTarget=‘var’只能以 <script> 标签的形式引入我们的库只能以全局变量的形式提供这些被依赖的模块
commonjsoutput.libraryTarget=‘commonjs’只能按照 commonjs 的规范引入我们的库被依赖模块需要按照 commonjs 规范引入
umdoutput.libraryTarget=‘umd’可以用<script>、commonjs、amd 引入被依赖模块需要按照对应方式引入

如果不是在开发一个 js 库,即没有设置 output.libraryoutput.libraryTarget 等配置信息,那么我们生成的打包文件只能以 <script> 标签的方式在页面中引入,因此那些被去除的依赖模块也只能以全局变量的方式引入。

resolve

Webpack 进行构建的时候会从入口文件开始(entry)遍历寻找各个模块的依赖,resolve 配置是帮助 Webpack 查找依赖模块的,通过 resolve 的配置,可以帮助 Webpack 快速查找依赖,也可以替换对应的依赖(比如开发环境用 dev 版本的 lib 等)。resolve 的基本配置语法如下:

module.exports = {
  resolve: {
      // resolve的配置
  }
};

resolve.extensions

resolve.extensions是帮助 Webpack 解析扩展名的配置,默认值:['.wasm', '.mjs', '.js', '.json'],所以我们引入 js 和 json 文件,可以不写它们的扩展名,通常我们可以加上 .css、.less等,但是要确保同一个目录下面没有重名的 css 或者 js 文件,如果存在的话,还是写全路径吧。

module.exports = {
  resolve: {
      extensions: ['.js', '.json', '.css']
  }
}

resolve.alias

resolve.alias 是最常用的配置,通过设置 alias 可以帮助 webpack 更快查找模块依赖,而且也能使我们编写代码更加方便。例如,我们在实际开发中经常会把源码都放到src文件夹,目录结构如下:

src/pages/demo/index.js中如果要引用src/lib/utils.js那么可以通过:import utils from '../../lib/utils'; ,如果目录更深一些,会越来越难看,这时可以通过设置 alias 来缩短这种写法,例如:

src
├── lib
│   └── utils.js
└── pages
    └── demo
        └── index.js
        
        module.exports = {
    resolve: {
        alias: {
            src: path.resolve(__dirname, 'src'),
            '@lib': path.resolve(__dirname, 'src/lib')
        }
    }
};

alias 还常被用于给生产环境和开发环境配置不同的 lib 库,例如下面写法,在线下开发环境使用具有 debug 功能的 dev 版本 San:

module.exports = {
  resolve: {
      alias: {
          san: process.env.NODE_ENV === 'production' ? 
          'san/dist/san.min.js' : 'san/dist/san.dev.js'
      }
  }
};

target

在项目开发中,我们不仅仅是开发 web 应用,还可能开发的是 Node.js 服务应用、或者 electron 这类跨平台桌面应用,这时候因为对应的宿主环境不同,所以在构建的时候需要特殊处理。webpack 中可以通过设置target来指定构建的目标(target)。

module.exports = {
    target: 'web' // 默认是 web,可以省略
};

module

在 webpack 解析模块的同时,不同的模块需要使用不同类型的模块处理器来处理,这部分的设置就在module配置中。module 有两个配置:module.noParsemodule.rules,我们一般只会用到module.rules

module.rules 是在处理模块时,将符合规则条件的模块,提交给对应的处理器来处理,通常用来配置 loader,其类型是一个数组,数组里每一项都描述了如何去处理部分文件。每一项 rule 大致可以由以下三部分组成:

  1. 条件匹配:通过testincludeexclude等配置来命中可以应用规则的模块文件;
  2. 应用规则:对匹配条件通过后的模块,使用use配置项来应用loader,可以应用一个 loader 或者按照从后往前的顺序应用一组 loader,当然我们还可以分别给对应 loader 传入不同参数;
  3. 重置顺序:一组 loader 的执行顺序默认是**从后到前(或者从右到左)**执行,通过enforce选项可以让其中一个 loader 的执行顺序放到最前(pre)或者是最后(post)。

举例说明,下面rule 的配置项,匹配的条件为:来自srctest文件夹,不包含node_modulesbower_modules子目录,模块的文件路径为.tsx.jsx结尾的文件。

{
    test: [/\.jsx?$/, /\.tsx?$/],
    include: [
        path.resolve(__dirname, 'src'),
        path.resolve(__dirname, 'test')
    ],
    exclude: [
        path.resolve(__dirname, 'node_modules'),
        path.resolve(__dirname, 'bower_modules')
    ]
}

除了直接在webpack.config.js使用 loader 的方式之外,还可以在对应的 JavaScript 文件中使用 loader:

const html = require('html-loader!./loader.html');
console.log(html);

上面的代码,实际是将loader.html的内容转化成 string 变量,直接给输出了,等同于:

// index.js
const html = require('./loader.html');
console.log(html);

// webpack.config.js
module.exports = {
    module: {
        rules: [{test: /\.html$/, use: ['html-loader']}]
    }
};

plugin 插件

plugin是 Webpack 的重要组成部分,通过plugin可以解决loader解决不了的问题。Webpack 本身就是有很多插件组成的,所以内置了很多插件,我们可以直接通过webpack对象的属性来直接使用,比如 new webpack.ProvidePlugin

plugins: [
    new webpack.ProvidePlugin({
      $: 'jquery',
      jQuery: 'jquery'
    })
]

除了内置的插件,我们也可以通过 NPM 包的方式来使用插件:

const CopyWebpackPlugin = require('copy-webpack-plugin')

new CopyWebpackPlugin({
  patterns: [
    {
      from: path.resolve(__dirname, './src/img'),
      to: path.resolve(__dirname, 'dist/img')
    }
  ]
})

loader面向的是解决某个或者某类模块的问题,而plugin面向的是项目整体,解决的是loader解决不了的问题。

mode 模式

通过配置 mode=development 或者 mode=production 来制定是开发环境打包,还是生产环境打包,比如生产环境代码需要压缩,图片需要优化,webpack 默认mode是生产环境,即mode=production

module.exports = {
    mode: 'development'
};

devtool

devtool是来控制怎么显示 source-map,通过 source-map 我们可以快速还原代码的错误位置。在开发者模式下,默认配置了source-map

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

但是由于 source-map 包含的数据量较大,而且生成算法需要计算量支持,所以 sourcemap 的生成会消耗打包的时间。一般在实际项目中,我个人推荐生产环境不使用,如果要使用可以用source-map;开发环境使用cheap-module-eval-source-map