历史
在JavaScript引入模块化的早期, 在浏览器中使用JavaScript有两种方式。
第一种,引用一些脚本存放;弊端是难扩展,脚本过多,不利于页面加载。第二种,使用一个包含所有项目代码的大型.js文件;弊端是作用域问题,单文件过大,代码可读性、可维护性差。
立即调用函数表达式(IIFE:Immediately invoked function expressions) 是一个在定义时就会立即执行的Javascript函数;函数的变量是不能在外部访问的,也就不会污染window 环境;它解决了大型项目作用域问题,基于此可以安全组合.js文件; IIFE这种方式,产生了Make, Gulp, Grunt, Broccoli 或 Brunch 等工具,它们会把所有的项目文件拼接在一起,这些工具被称为任务执行器。但这种方式无法实现跨文件重用脚本;当使用三方库时,也无法删除无用的代码,比如使用lodash库中的某个函数时,却会引入整个库。
示例如图:
Node.js 发布于2009年5月,是一个基于Chrome引擎的运行环境,可以在浏览器环境之外的计算机和服务器中使用。Node.js没有html和script标签,它使用CommonJS的 require 机制来加载Javascript代码块,它的问世才使得Javascript逐步具备模块化能力;但CommonJS仅是Node.js的模块化解决方案,起初浏览器是不支持CommonJS的模块化方案的,为了实现基于CommonJS模块化编程,并且能够使其运行于浏览器的打包工具,于是便有了Browserify, RequireJS 和 SystemJS等打包工具。下图为使用RequireJS实现模块化的示例:
ECMAScript Modules 简称ESM,是官方标准模块格式,于 2015 年推出,用于打包JavaScript,以便重复使用。(ECMAScript : 一种JavaScript的标准)。它的模块是使用各种 import 和 export 语句定义的。Node.js是完全支持ESM的,并提供了与CommonJS的互操作性。目前浏览器大部分都已经支持了。
概念
虽然时至今时,浏览器对应ESM提供了良好的支持,但webpack依然是Javascript工具链的关键部分。webpack是个用于现代JavaScript应用程序的静态模块打包工具。 它不仅可以支持ESM和CommonJS模块化编程,而且还可以支持或扩展支持许多不同的静态资源,例如:Files,Images, Fonts, JS, CSS, HTML等等。同时也提供多种功能,如合并模块,代码最小化(消除空格,备注,垃圾代码或减少代码)、SASS、TS编译等。
当webpack打包应用程序时,会从一个或多个入口文件开始递归构建一个依赖关系图,然后将项目所需的每个模块组合成一个或多个bundles, 它们均为静态资源,可由浏览器加载。这些依赖可以是代码资源,也可以是非代码资源,如Files,Images, Fonts等。
创建项目
接下来我们从0到1,逐步搭建一个基于webpack的项目,并针对开发、生产模式下一些场景,逐步示例介绍,来让我们对webpack从使用和概念上进一步的了解。
#1.创建一个项目文件夹并接入文件夹
mkdir mywebpackapp
cd mywebpackapp
#2.创建一个package.json
npm init -y
#3.安装webpack、webpack-cli
npm install --save-dev webpack webpack-cli
#4.创建 webpack.config.js
touch webpack.config.js
#5.创建 index.html、index.js
touch index.html
touch index.js
touch index.css
补充对应内容后的项目骨架如下图:
Entry&Output
webpack配置中 entry和output分别用来指示程序从何处开始构建,以何种方式输出到何处。entry入口起点可以是一个也可以是多个:
- 当只有一个入口时,可以通过
output.filename命名输出后的文件,也可以通过修改entry值为对象,则其属性的key为输出后的文件名称,value为入口文件路径(当entry为字符串或字符串数组时,默认名称为main)。
- 当有多个入口时,需要保证每个文件名称都具有唯一性,此时需要使用占位符(可替换模板字符串),比如,
[id]、[name]、[chunkhash]、[contenthash]。
配置output.clean = true,可以每次打包新的产物时,清除旧的产物。
Plugin
插件 是webpack的支柱功能,它是一个具有apply方法的JavaScript对象。apply方法会被webpack compiler调用,并且插件在其整个生命周期都可以访问到compiler对象,因此他可以hook整个编译的生命周期。
上述示例,配置的最终产物,缺少了index.html,只有js文件;因此我们需要产出index.html并且引入我们每次编译后的js bundle文件。
html-webpack-plugin此插件可以生成html5文件,并且会使用script标签引入我们的js bundle,我们使用它来构建我们的index.html文件,首先我们需要安装插件包:
npm install --save-dev html-webpack-plugin
配置与最终结果如下图:
此时在浏览器运行index.html,便会执行js文件。
loader
loader用于对模块的源代码转换,它相当于编译期间的一个任务。起初webpack只理解javaScript文件,但是webpack将每个作为模块导入的文件视为依赖项,并将其添加到依赖关系图中。因此为了处理静态资源的导入,例如:Files,Images, Fonts, CSS, Json等,webpack使用Loader来将这些文件加载到bundle中。
Load CSS
上述示例,通过js的方式为页面增加了内容,并设置了css样式,如下图:
一切准备就绪,执行打包脚本,发现报错:You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file。
为了能够导入CSS,我们需要安装style-loader和css-loader,并在module.rules下配置这些loader:
npm install --save-dev style-loader css-loader
具体配置如下:
重新打包后,浏览器运行index.html,会发现css样式生效了;不过需要注意的是 导入css文件,应保证 loader 的先后顺序:style-loader 在前,而 css-loader 在后。 否则webpack可能会抛出错误。
loader 可以链式调用, 链中的每个 loader 都将对资源进行依次转换和传递。最后,webpack期望链中的最后一个loader返回javaScript。
静态资源模块
在webpack5之前的版本中,加载静态资源模块,需要使用raw-loader、url-loader、file-loader;
raw-loader: 加载文件的原始内容(utf8),将文件作为字符串进行导入;file-loader: 将文件导出到输出文件夹,并返回相对的URL;url-loader: 和file-loader一样,但是当文件小于限制的大小时便会返回data URI(如:data:image/svg+xml;base64,....);
const config = {
//...
module: {
rules: [
// {
// test: /\.(png|svg|jpg|jpeg|gif)$/i,
// use: [
// 'url-loader?limit=8192',//小于8k时内联为data urI
// ]
// },,
{
test: /\.(png|jpg)$/i,
use: [
{
loader: 'url-loader',
options: {
limit: 8192 //小于8k时内联为data urI
}
}
]
},
{
test: /\.txt$/i,
use: 'raw-loader',
},
{
test: /\.(svg|gif)$/,
use: [
{
loader: 'file-loader',
options: {
name: '[path][name].[ext]',
},
},
],
},
]
}
}
webpack5开始,新增资源模块类型(asset modules type),通过四种新的模块类型,替换了所有这些loader:
asset/resource: 输出文件并导出相对URL;之前使用file-loader实现。asset/inline: 导出资源的data URI; 之前使用url-loader实现。asset/source: 导出资源的源代码;之前使用raw-loader实现。asset: 在导出一个data URI和导出一个单独的文件之间自动选择;之前使用url-loader,并配置资源大小限制来实现。
const config = {
//...
module: {
rules: [
//...
{
test: /\.png/,
type: 'asset/resource' //导出文件返回相对URL
},
{
test: /\.svg/,
type: 'asset/inline', //以`data URI`内联导入资源
},
{
/*
import exampleText from './example.txt';
block.textContent = exampleText; // 'Hello world'
*/
test: /\.txt/,
type: 'asset/source', //导入资源的源代码
},
]
}
}
自定义输出文件名
默认情况下,asset/resource 模块以 [hash][ext][query] 文件名导出到输出目录。有两种方式可以修改输出的文件名:
- 配置
output.assetModuleFilename
const config = {
entry: {
main: './index.js',
code: './code.js'
},//入口文件,默认输出为main.js
output: {
clean: true,//每次打包清除旧的编译产物
path: path.resolve(__dirname, 'dist'),//输出到dist目录下
filename: '[name].[chunkhash].js',
assetModuleFilename: 'images/[name][hash][ext]' ///修改输出的资源文件名称
},
module: {
rules: [
///...
{
type: 'asset/resource',
test: /\.(png|svg|jpg|jpeg|gif)$/i
}
]
}
}
- 发送资源到指定目录
const config = {
entry: {
main: './index.js',
code: './code.js'
},
output: {
clean: true,
path: path.resolve(__dirname, 'dist'),
filename: '[name].[chunkhash].js',
},
module: {
rules: [
///...
{
type: 'asset/resource',
test: /\.(png|svg|jpg|jpeg|gif)$/i,
generator: {
filename: 'static/[name][hash][ext]' ///指定输出到dist/static/...
}
}
]
}
}
rule.generator.filename 与 output.assetModuleFilename 相同,并且仅适用于 asset 和 asset/resource 模块类型。
自定义data URI生成器
webpack 输出的 data URI,默认是使用 Base64 算法编码的文件内容。如果需要自定义,则需要自定义函数来编码文件内容:
const svgToMiniDataURI = require('mini-svg-data-uri');
const config = {
///...
module: {
rules: [
///...
{
test: /\.svg/,
type: 'asset/inline',
generator: {
dataUrl: content => {
content = content.toString();
return svgToMiniDataURI(content);
}
}
}
]
}
}
通用资源类型
通用资源类型asset会按照默认的条件在resource和inline之间进行选择;默认条件为:小于8kb为文件视为inline模块类型,否则视为resource模块类型。可以通过Rule.parser.dataUrlCondition.maxSize 来修改此条件:
const svgToMiniDataURI = require('mini-svg-data-uri');
const config = {
///...
module: {
rules: [
///...
{
test: /\.svg/,
type: 'asset/inline',
parser: {
dataUrlCondition: {
maxSize: 12 * 1024 // 12kb
}
},///小于12kb inline 否则 resource
generator: {
dataUrl: content => {
content = content.toString();
return svgToMiniDataURI(content);
}
}
}
]
}
}
resolve
模块化编程中,一个模块可以作为另一个模块的依赖模块,并通过require/import语句进行引用,当最终需要打包模块时,需要使用绝对路径来找到模块的代码。webpack内部使用enhanced-resolve来解析文件路径,而webpack配置项resolve可以将我们的自定义选项传递给enhanced-resolve。webpack使用enhanced-resolve能解析三种文件路径:
- 直接引入的绝对路径。
- 拼接上下文目录的相对路径。
- 指定检索的模块路径。通过
resolve.modules配置;也可以通过resolve.alias配置别名替换初始模块路径的方式实现。
import '/home/me/file';//绝对路径
//or
import '../../file';//相对路径
//or
import 'module/lib/file'; //模块路径
resolve.modules
配置模块解析时检索的目录,比如解析import _ from 'lodash';默认为node_modules。
module.exports = {
//...
resolve: {
modules: ['node_modules'],
},
};
如果需要添加一个优先于node_modules搜索的目录:
const path = require('path');
module.exports = {
//...
resolve: {
modules: [path.resolve(__dirname, 'src'), 'node_modules'],
},
};
resolve.alias
模块解析时,webpack配置的别名可以替换初始模块的路径,这种方式可以让我们在引入模块时变的简单。
比如,项目中常见的引入语句:
import { daysBetween } from '../../../utils/date'
当我们移动了代码内容后,便需要重新修改import的模块路径。因此我们可以配置为其配置别名:
const path = require('path');
module.exports = {
//...
resolve: {
alias: {
Utils: path.resolve(__dirname, 'src/utils/'),
},
},
};
之后导入模块,便可以使用别名来替换初始模块的路径。
import { daysBetween } from 'Utils/date'
也可以在别名(alias对象的键)的末尾添加$表示精准匹配:
const path = require('path');
module.exports = {
//...
resolve: {
alias: {
xyz$: path.resolve(__dirname, 'path/to/file.js'),
},
},
};
这种配置产生的结果:
import Test1 from 'xyz'; // 精确匹配,所以 path/to/file.js 被解析和导入
import Test2 from 'xyz/file.js'; // 非精确匹配,触发普通解析
resolve.extensions
在进行模块解析时,如果解析到的文件没有扩展名称时,webpack便会通过resolve.extensions提供的扩展名选项为其进行匹配,基于此我们可以使用import 'index'代替import 'index.js'(不必使用扩展)。
如果有多个文件有相同的名字,但后缀名不同,webpack 会解析列在数组首位的后缀的文件 并跳过其余的后缀。
module.exports = {
//...
resolve: {
extensions: ['.js', '.json', '.vue'],
},
};
需要注意的是resolve.extensions会覆盖默认数组,这就意味使用自定义后webpack将不会使用默认的扩展来解析模块,如果要继续使用默认扩展名,可以在数组中加入...:
module.exports = {
//...
resolve: {
extensions: ['.ts', '...'],
},
};
代码分离
代码分离是webpack最亮眼的特性之一,可以把代码分离到不同的bundle中,并按需加载或者并行加载这些文件。代码分离可以获取最小的bundle,控制资源加载的优先级。
常用的代码分离有三种:
- 入口起点:使用
entry手动配置分离。 - 防止重复:使用
entry配置下的dependOn或者SplitChunksPlugin去重和分离chunk。 - 动态导入:通过模块内联函数的调用来分离代码。
入口起点
这种代码分离的方式比较直观,也存在问题。以下是示例工程目录及文件:
webpack通过入口起点分离代码的配置如下:
const config = {
entry: {
main: './index.js',
code: './code.js'
},
output: {
clean: true,
path: path.resolve(__dirname, 'dist'),
filename: '[name].[chunkhash].js',
assetModuleFilename: 'images/[name][hash][ext]'
},
//...
}
module.exports = config;
编译输出的结果:
这种方式存在的问题:
main.js和code.js中的lodash会被重复引入到各自的bundle中。- 不够灵活,不能动态分离
main.js和code.js的公共代码。
防止重复
入口依赖
webpack通过入口依赖分离代码的配置如下:
const config = {
entry: {
main: {
import: './index.js',
dependOn: 'commonChunk'
},
code: {
import: './code.js',
dependOn: 'commonChunk'
},
commonChunk: ['lodash']
},
output: {
clean: true,
path: path.resolve(__dirname, 'dist'),
filename: '[name].[chunkhash].js',
assetModuleFilename: 'images/[name][hash][ext]'
},
//...
}
编译输出:
为了避免一个
html页面上使用多个入口会遇到的问题,还需设置optimization.runtimeChunk: 'single';
搞清楚runtimeChunk之前,我们需要先知道webpack构建程序的三种代码类型:
- 自己编写的代码
- 第三方代码或库
webpack的runtime和manifest,管理所有的模块交互。
manifest: 是webpack编译后用来记录所有模块详细信息的数据。
runtime: 是webpack编译后的代码在浏览器中运行时,webpack用来连接模块化应用程序需要的所有代码;包括模块加载和解析逻辑、连接模块逻辑、延迟加载逻辑。它会通过manifest中的模块数据来进行加载和解析。
optimization.runtimeChunk可以将webpack的runtime代码拆分为一个单独的chunk。将其设置为single可以为所有的chunk创建一个runtime bundle。
基于此修改配置如下:
const config = {
entry: {
main: {
import: './index.js',
dependOn: 'commonChunk'
},
code: {
import: './code.js',
dependOn: 'commonChunk'
},
commonChunk: ['lodash']
},
output: {
clean: true,
path: path.resolve(__dirname, 'dist'),
filename: '[name].[chunkhash].js',
assetModuleFilename: 'images/[name][hash][ext]'
},
//...
optimization:{
runtimeChunk: 'single'
}
}
编译后的产物:
通过编译产物发现这种方式不仅分离了代码还分离了公共模块commonChunk,同时还多了一个runtime。
基于入口依赖的多页面应用配置:
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const config = {
entry: {
main: {
import: './index.js',
dependOn: 'commonChunk'
},
code: {
import: './code.js',
dependOn: 'commonChunk'
},
commonChunk: ['lodash']
},
output: {
clean: true,
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
assetModuleFilename: 'images/[name][hash][ext]'
},
plugins: [
new HtmlWebpackPlugin({
filename:'main.html',
template: './index.html',
chunks: ['main','commonChunk'],//默认是all
}),
new HtmlWebpackPlugin({
filename:'code.html',
template: './index.html',
chunks: ['code','commonChunk'],//默认是all
}),
],
//...
}
module.exports = config;
编译输出的产物
对比发现多了一个页面,并且页面运行各自加载js代码。
SplitChunksPlugin
SplitChunksPlugin插件可以将公共的依赖模块提取到已有的入口chunk中或提取到一个新生成的chunk中,基于此我们可以使用它将示例中重复的lodash模块提取成单独的chunk。
SplitChunksPlugin在webpack中是开箱即用的,它通过以下条件来自动拆分chunks:
- 新的
chunk可以被共享,或模块来自于node_moudles文件夹 - 新的
chunk体积大于20kb - 当按需加载
chunks时,并行请求的最大数量小于或等于30? - 当加载初始化页面时,并发请求的最大数量小于或等于
30?
当尝试满足后两个条件时,最好使用较大的chunks。?
webpack通过SplitChunksPlugin分离代码的配置如下:
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const config = {
entry: {
main: './index.js',
code: './code.js',
},//入口文件,默认输出为main.js
output: {
clean: true,//每次打包清除旧的编译产物
path: path.resolve(__dirname, 'dist'),//输出到dist目录下
filename: '[name].[chunkhash].js',
assetModuleFilename: 'images/[name][hash][ext]'
},
//...
optimization:{
splitChunks: {
// 有效值为 all,async 和 initial
// all 意味着 chunk 可以在异步和非异步 chunk 之间共享
chunks: 'all'
}
}
}
module.exports = config;
通过编译后的产物可以看出成功分离了三方库lodash:
动态导入
使用ESM的import()或者require.ensure来实现动态导入。改变index.js中内容:
// import _ from 'lodash';
import './index.css'///执行导入
// function component() {
// const element = document.createElement('div');
// element.innerHTML = _.join(['Hello', 'webpack'], ' ');
// element.className = 'title'
// return element;
// }
async function component() {
try {
const { default: _ } = await import('lodash');
const element = document.createElement('div');
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
element.className = 'title';
return element;
} catch (error) {
console.log('加载组件失败');
}
}
// document.body.appendChild(component());
component().then(component=>{
document.body.appendChild(component);
})
修改webpack的配置文件:
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const config = {
entry: {
main: './index.js',
// code: './code.js',
},//入口文件,默认输出为main.js
//....
// optimization:{
// splitChunks: {
// // 有效值为 all,async 和 initial
// // all 意味着 chunk 可以在异步和非异步 chunk 之间共享
// chunks: 'all'
// }
// }
}
module.exports = config;
编译后产物分离了三方库lodash:
缓存
前端项目的上线流程简单总结主要就是webpack打包生成可部署的/dist目录,最后将/dist目录中的内容部署到server上。如此便可以在client(浏览器)访问server上的网站及资源,这个过程中资源的获取是比较耗时的,因此浏览器中使用了缓存的技术(命中要素通常为资源文件名是否变化),可以通过命中缓存以降低网络流量,使网页的加载速度更快。
为了有效利用client的缓存技术,webpack针对缓存的配置是很有必要的;它可以保证编译产物能够被client缓存,而在文件内容变化后,能够请求到新的文件。
Output Filename
通过output.filename提供的可替换模板字符串(如,[id]、[name]、[chunkhash])来配置输出的文件名为[contenthash],这个占位符字符串将根据资源内容创建出唯一的hash。也就是说当资源内容发生变化时[contenthash]也会变化。
webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const config = {
entry: {
main: './index.js',
},
output: {
clean: true,
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',/// 资源名称
assetModuleFilename: 'images/[name][hash][ext]'
},
//...
}
module.exports = config;
编译输出的产物:
资源内容不作改变再次编译后的产物:
如果不作修改再次构建,文件名有可能会发生改变,这种不确定的输出主要是和webpack的版本有关系,新版本可能不会发生改变,但webpack仍旧推荐提取引导模板。
提取引导模板
引导模板(boilerplate): webpack运行时所需要的引导代码,主要是runtime和manifest。每次从config.entry中配置的入口chunk开始编译,会包含这部分的引导代码。
简单讲就是将rumtime拆分成一个单独的模块,正如代码分离所讲,可以配置optimization.runtimeChunk: 'single'来为所有的chunk创建一个runtime bundle。
webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const config = {
entry: {
main: './index.js',
},
output: {
clean: true,
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',/// 资源名称
assetModuleFilename: 'images/[name][hash][ext]'
},
//...
optimization: {
runtimeChunk: 'single',
},
}
module.exports = config;
编译后的产物多了runtime chunk:
提取三方库
第三方库在项目中使用时,几乎不会频繁的改动,因此将这部分代码提取到单独的vendor chunk中,可以有效的利用client的缓存机制,有效减少资源请求。这部分的提取主要依赖SplitChunksPlugin,我们可以通过配置optimization.splitChunks来为SplitChunksPlugin传递参数,从而实现三方库的提取。
webpack.config.js
const config = {
entry: {
main: './index.js',
},
output: {
clean: true,
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
assetModuleFilename: 'images/[name][hash][ext]'
},
//...
optimization: {
runtimeChunk: 'single',
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
///提取特定的库
// lodash: {
// name: 'lodash',
// test: /[\\/]node_modules[\\/]lodash[\\/]/,
// chunks: 'all',
// priority: 3, //优先级
// reuseExistingChunk: true,
// enforce: true
// },
}
}
},
}
module.exports = config;
编译后输出的产物:
模块标识符
为index.js导入一个来自code.js的方法。
再次基于上一步的webpack配置进行编译,输出产物如下:
通过对比发现runtime hash因为manifest包含了一个新的模块引用,而发生了改变;main hash因为内容的改变也发生了改变。这一切都是符合预期的,但是demo中webpack的版本是最新的5.75.0,因此vendor的hash并没有发生变化,但是在其他较低的webpack版本中,vendor输出的hash可能会发生变化,这是因为每个module.id会默认的基于解析的顺序进行增量改变,即当解析顺序发生改变,module.id也会随之改变,这就会导致vendor的hash也会因不同的module.id而发生变化。
webpack可以通过optimization.moduleIds = 'deterministic'来解决这种问题。
webpack.config.js
const config = {
entry: {
main: './index.js',
},
output: {
clean: true,
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
assetModuleFilename: 'images/[name][hash][ext]'
},
//...
optimization: {
moduleIds: 'deterministic',
runtimeChunk: 'single',
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
///提取特定的库
// lodash: {
// name: 'lodash',
// test: /[\\/]node_modules[\\/]lodash[\\/]/,
// chunks: 'all',
// priority: 3, //优先级
// reuseExistingChunk: true,
// enforce: true
// },
}
}
},
}
module.exports = config;