万字长文,带你从零学习Webpack

3,393 阅读31分钟

一直以为,我的Webpack就是复制粘贴的水平,而对Webpack的知识真的很模糊,甚至是纯小白。所以前段时间开始对Webpack进行比较系统的学习。

学习完成后,我抽空整理了笔记,前前后后也花了一周多。最后觉得可以分享出来,让对Webpack还很模糊的朋友,可以学习一下。

当然,读完本文,你会发现Webpack还有更多更深的东西值得我们去学习,因此这只是一个开始,从零开始。

module、chunk和bundle

在学习webpack之前,我们需要先来捋一捋三个术语——modulechunkbundle

过一下概念

module

先看看webpack官方对module的解读:

Module是离散功能块,相比于完整程序提供了更小的接触面。精心编写的模块提供了可靠的抽象和封装界限,使得应用程序中每个模块都具有条理清楚的设计和明确的目的。

其实简单来说,module模块就是我们编写的代码文件,比如JavaScript文件、CSS文件、Image文件、Font文件等等,它们都是属于module模块。而module模块的一个特点,就是可以被引入使用。

chunk

同样的先看看官方解读:

webpack 特定术语在内部用于管理捆绑过程。输出束(bundle)由块组成,其中有几种类型(例如 entrychild )。通常,块直接与输出束 (bundle)相对应,但是,有些配置不会产生一对一的关系

其实chunkwebpack打包过程的中间产物,webpack会根据文件的引入关系生成chunk,也就是说一个chunk是由一个module或多个module组成的,这取决于有没有引入其他的module

Bundle

先看看官方解读:

bundle 由许多不同的模块生成,包含已经经过加载和编译过程的源文件的最终版本。

bundle其实是webpack的最终产物,通常来说,一个bundle对应这一个chunk

总结

其实modulechunkbundle可以说是同一份代码在不同转换场景的不同名称:

  • 我们编写的是module
  • webpack处理时时chunk
  • 最终生成供使用的是bundle

实践一下

我们通过一个小demo来过一下,现在有一个项目,路径如下:

src/
├── index.css
├── index.js
├── common.js
└── utils.js

然后我们有两个入口文件,一个是index.js,一个是utils.js,在index.js中引入了index.csscommon.js。然后通过webpack打包出来了index.bundle.cssindex.bundle.jsutils.bundle.js

好,介绍完背景后,我们就可以来分析一下modulechunkbundle

首先,我们编写的代码,就是module,也就是说index.csscommon.jsindex.jsutils.js共四个module文件。

其次,我们有两个入口文件,分别为index.jsutils.js,并且它们最后是独立打包成bundle的,从而在webpack打包过程中就会形成两个chunk文件,而由index.js形成chunk还包含着index.js引入的module——common.jsindex.css

最后,我们打包出来了index.bundle.cssindex.bundle.jsuitls.bundle.js,这三个也就是bundle文件。

module-chunk-bundle.png

最后,我们可以总结一下三者之间的关系:一个budnle对应着一个chunk,一个chunk对应着一个或多个module

初始化Webpack项目

接下来,我们通过一步步实践,来慢慢学习webpack,这篇文章使用的是webpack5

首先,新建一个项目文件夹,然后初始化项目。

yarn init -y

然后安装一下webpack。当我们使用webpack时,还需要安装webpack-cli

因为webpack只是在开发环境才会使用到,所以我们只需要添加到devDependencies即可。

# webpack -> 5.47.0, webpack-cli-> 4.7.2
yarn add webpack webpack-cli -D

然后再项目中新建src路径,再新建一个index.js

console.log("Hello OUDUIDUI");

然后执行npx webpack,则执行webpack打包。这时你的项目就会多一个dist文件夹,并且在dist文件夹中会看到一个main.js,里面的代码跟index.js一样。

当然,我们可以在package.json中编辑script命令:

"scripts": {
  "dev": "webpack"
}

然后执行yarn dev,也可以成功打包。

Webpack配置文件

如果使用过webpack的朋友应该知道,webpack其实有一个配置文件——webpack.config.js

但为什么前面的初始化测试时,我们没有编辑配置文件却可以成功打包?这是因为webpack会有一个默认配置,当它检测到我们没有配置文件的时候,它默认会使用自己的默认配置。

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'main.js',
  },
};

首先,我们简单来过一下这些默认配置叭。

entry和output

entry选项是用来配置入口文件的,它可以是字符串、数组或者对象类型。webpack默认只支持jsjson文件作为入口文件,因此如果引入其他类型文件会保存。

output选项是设置输出配置,该选项必须是对象类型,不能是其它类型格式。在output对象中,必填的两个选项就是导出路径path和导出bundle文件名称filename。其中path选项必须为绝对路径。

entryoutput的配置,对于不同的应用场景的配置也会有所不同。

单入口单输出

我们最普遍的就是单个入口文件,然后打包成单个bundle文件。这种应用场景下,entry可以使用字符串的形式,则跟默认配置文件类似:

entry: './src/index.js'

多入口单输出

当我们的项目需要有多个入口文件,但只需要一个输出bundle的时候,这时候entry可以使用数组的形式:

entry: ['./src/index_1.js', './src/index_2.js']

注意:此时其实只有一个chunk

多入口多输出

当我们的项目同时多个入口文件,并且它们需要单独打包,也就是意味着会有多个bundle文件输出,此时我们的entry需要使用对象形式,并且对象key对应的对应chunk的名称。

entry: {
  index: "./src/index.js",  // chunkName为index
  main: "./src/main.js"     // chunkName为main
}

此时,我们的output.filename也不能写死了,这时候webpack提供了一个占位符[name]给我们使用,它会自动替换为对应的chunkName

output: {
   path: path.resolve(__dirname, 'dist'),
   filename: '[name].js'  // [name]占位符会自动替换为chunkName
},

根据上面的配置,最后会打包出index.jsmain.js

补充

在单入口单输出的应用场景下,entry也可以使用对象的形式,从而来自定义chunkName,然后output.filename也使用[name]占位符来自动匹配。当然也可以使用数组,但是不太大必要。

entry使用数组或字符串的时候,chunkName默认为main,因此如果output.filename使用[name]占位符的时候,会自动替换为main

mode

在前面的打包测试的时候,命令行都会报一个警告:

WARNING in configuration
The 'mode' option has not been set, webpack will fallback to 'production' for this value.
Set 'mode' option to 'development' or 'production' to enable defaults for each environment.

这是因为webpack需要我们配置mode选项。

wepack给我们提供了三个选项,即nonedevelopmentproduction,而默认就是production

三者的区别呢,在于webpack自带的代码压缩和优化插件使用。

  • none:不使用任何默认优化选项;

  • development:指的是开发环境,会默认开启一些有利于开发调试的选项,比如NamedModulesPluginNamedChunksPlugin,分别是给modulechunk命名的,而默认是一个数组,对应的chunkName也只是下标,不利于开发调试;

  • production:指的是生产环境,则会开启代码压缩和代码性能优化的插件,从而打包出来的文件也相对nonedevelopment小很多。

当我们设置mode之后,我们可以在process.env.NODE_ENV获取到当前的环境

因此我们可以在配置文件上文件上配置mode

const path = require('path');

module.exports = {
    entry: './src/index.js',
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].js',
    },
    // 配置mode选项
    mode: "development"
};

webpack也给我们提供了另一种方式,就是在命令行中配置,也就是加上--mode

// package.json
"scripts": {
  "dev": "webpack --mode development",
  "build": "webpack --mode production"
}

devtool

聊完mode后,说到开发调试,不难想起的就是sourceMap。而我们可以在配置文件中,使用devtool开启它。

const path = require('path');

module.exports = {
    entry: './src/index.js',
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].js',
    },
  	// 开启source-map
    devtool: "source-map"
};

打包后,你的dist中就会多了一个main.js.map文件。

当然,官方不止提供这么一个选项,具体的可以去官网看看,这里就说其他几个比较常用的选项。

  • none:不会生成sourceMap

  • eval:每个模块都会使用eval()执行,不建议生成环境中使用;

  • cheap-source-map:生成sourceMap,但是没有列映射,则只会提醒是在代码的第几行,不会提示到第几列;

  • inline-source-map:会生成sourceMap,但不会生成map文件,而是将sourceMap放在打包文件中。

module

前面我们有提到过,就是webpack的入口文件只能接收JavaScript文件和JSON文件。

但我们通常项目还会有其他类型的文件,比如htmlcss、图片、字体等等,这时候我们就需要用到第三方loader来帮助webpack来解析这些文件。理论上只要有相应的loader,就可以处理任何类型的文件。

webpack官网其实提供了很多loader,已经能满足我们日常使用,当然我们也可以去github找找别人写的loader或者自己手写loader来使用。

而对于loader的配置,是写着module选项里面的。module选项是一个对象,它里面有一个rules属性,是一个数组,在里面我们可以配置多个匹配规则。

而匹配规则是一个对象,会有test属性和use属性,test属性一般是正则表达式,用来识别文件类型,而use属性是一个数组,里面用来存放对该文件类型使用的loader

module: {
    rules: [
        {
          test: /\.css$/,  // 识别css文件
          use: ['style-loader', 'css-loader']  // 对css文件使用的三个loader
        }
    ]
}

对于use数组的顺序是有要求的,webpack会根据自后向前的规则去执行loader。也就是说,上面的例子webpack会先执行css-loader,再执行style-loader

其次,当我们需要对对应loader提供配置的时候,我们可以选用对象写法:

module: {
    rules: [
        {
          test: /\.css$/,  
          use: [
            'style-loader', 
            {
              	// loader名称
              	loader: 'css-loader',
              	// loader选项
              	options: {
                  	... 
                }
            }
          ] 
        }
    ]
}

在后面我们根据实际应用场景再讲讲module的使用。

plugins

webpack还提供了一个plugins选项,让我们可以使用一些第三方插件,因此我们可以使用第三方插件来实现打包优化、资源管理、注入环境变量等任务。

同样的,webpack官方也提供了很多plugin

plugins选项是一个数组,里面可以放入多个plugin插件。

plugins: [
  new htmlWebpackPlugin(),
  new CleanWebpackPlugin(),
  new miniCssExtractPlugin(),
  new TxtWebpackPlugin()
]

而对于plugins数组对排序位置是没有要求,因为在plugin的实现中,webpack会通过打包过程的生命周期钩子,因此在插件逻辑中就已经设置好需要在哪个生命周期执行哪些任务。

实现一下常见的应用场景

HTML模板

当我们是Web项目的时候,我们必然会存在html文件去实现页面。

而对于其他类型的文件,比如css、图片、文件等等,我们是可以通过引入入口js文件,然后通过loader进行解析打包。而对于html文件,我们不可能将其引入到入口文件然后解析打包,反而我们还需要将打包出来的bundle文件引入html文件去使用,

因此,其实我们需要实现的操作只有两个,一个是复制一份html文件到打包路径下,另一个就是将打包出来的bundle文件自动引入到html文件中去。

这时候我们需要使用一个插件来实现这些功能——html-webpack-plugin

# 5.3.2
yarn add html-webpack-plugin -D

安装插件后,我们先在src文件下新建一下index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Webpack Demo</title>
</head>
<body>
    <div>Hello World</div>
</body>
</html>

这里面我们暂时不需要引入任何模块。

接下来配置一下webpack。一般plugin插件都是一个类,而我们需要在plugins选项中需要创建一个插件实例。

对于htmlWebpackPlugin插件,我们需要传入一些配置:html模板地址template和打包出来的文件名filename

const path = require('path');
// 引入htmlWebpackPlugin
const htmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: './src/index.js',
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].js',
    },
    plugins: [
      	// 使用htmlWebpackPlugin插件
        new htmlWebpackPlugin({
         	 // 指定html模板
            template: './src/index.html',  
          	// 自定义打包的文件名
            filename: 'index.html'
        })
    ]
};

接下来执行一下打包,就会发现dist文件下会生成一个index.html。打开会发现,webpack会自动将bundle文件引入:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Webpack Demo</title>
<script defer src="main.js"></script></head>
<body>
    <div>Hello World</div>
</body>
</html>

如果我们有多个chunk的时候,我们可以指定该html要引入哪些chunk。在htmlWebpackPlugin配置中有一个chunks选项,是一个数组,你只需要加入你想引入的chunkName即可。

const path = require('path');
// 引入htmlWebpackPlugin
const htmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: {
      	index: './src/index.js',
      	main: './src/main.js'
    },
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].js',
    },
    plugins: [
        new htmlWebpackPlugin({
            template: './src/index.html',  
            filename: 'index.html',
          	chunks: ["index"]  // 只引入index chunk
        })
    ]
};

打包完成后,dist文件下会出现index.htmlindex.jsmain.js,但是index.html只会引入index.js

<!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>Document</title>
<script defer src="index.js"></script></head>
<body>
    HelloWorld!
</body>
</html>

如果我们需要实现多页面的话,只需要再new一个htmlWebpackPlugin实例即可,这里就不再多说。

清理打包路径

在每次打包前,我们其实都需要去清空一下打包路径的文件。

如果文件重名的话,webpack还会自动覆盖,但是实际中我们都会在打包文件名称中加入哈希值,因此清空的操作不得不实现。

这时候我们需要使用一个插件——clean-webpack-plugin

yarn add clean-webpack-plugin -D

然后只需引入到配置文件且在plugins配置就可以使用了。

const path = require('path');
// 引入CleanWebpackPlugin
const {CleanWebpackPlugin} = require('clean-webpack-plugin');

module.exports = {
    entry: './src/index.js',
    output: {
        path: path.resolve(__dirname, 'dist/'),
        filename: '[name].js',
        publicPath: ''
    },
    plugins: [
      	// 使用CleanWebpackPlugin
        new CleanWebpackPlugin(),
    ]
};

有些情况下,我们不需要完全清空打包路径,这时候我们可以使用到一个选项,叫cleanOnceBeforeBuildPatterns,它是一个数组,默认是[**/*],也就是清理output.path路径下所有东西。而你可以在里面输入只想删除的文件,同时我们可以输入不想删除的文件,只需要在前面加上一个!

需要注意的是,cleanOnceBeforeBuildPatterns这个选项是可以删除打包路径下之外的文件,只需要你配上绝对路径的话。因此CleanWebpackPlugin还提供了一个选项供测试——dry,默认是为false,当你设置为true后,它就不会真正的执行删除,而是只会在命令行打印出被删除的文件,这样子更好的避免测试过程中误删。

const path = require('path');
const {CleanWebpackPlugin} = require('clean-webpack-plugin');

module.exports = {
    entry: './src/index.js',
    output: {
        path: path.resolve(__dirname, 'dist/'),
        filename: '[name].js',
        publicPath: ''
    },
    plugins: [
        new CleanWebpackPlugin({
          	// dry: true   // 打开可测试,不会真正执行删除动作
            cleanOnceBeforeBuildPatterns: [
                '**/*',  // 删除dist路径下所有文件
                `!package.json`,  // 不删除dist/package.json文件
            ],
        }),
    ]
};

Webpack本地服务

当我们使用webpack的时候,不仅仅只是用于打包文件,大部分我们还会依赖webpack来搭建本地服务,同时利用其热更新的功能,让我们更好的开发和调试代码。

接下来我们来安装一下webpack-dev-server

# 版本为3.11.2
yarn add webpack-dev-server -D

然后执行下列代码开启服务:

npx webpack serve

或者在package.json配置一下:

"scripts": {
  "serve": "webpack serve --mode development"
}

然后通过yarn serve运行。

这时,webpack会默认开启http://localhost:8080/服务(具体看你们运行返回的代码),而该服务指向的是dist/index.html

但你会发现,你的dist中其实是没有任何文件的,这是因为webpack将实时编译后的文件都保存到了内存当中。

webpack-dev-server的好处

其实webpack自带提供了--watch命令,可以实现动态监听文件的改变并实时打包,输出新的打包文件。

但这种方案存在着几个缺点,一就是每次你一修改代码,webpack就会全部文件进行重新打包,这时候每次更新打包的速度就会慢了很多;其次,这样的监听方式做不到热更新,即每次你修改代码后,webpack重新编译打包后,你就得手动刷新浏览器,才能看到最新的页面结果。

webpack-dev-server,却有效了弥补这两个问题。它的实现,是使用了express启动了一个http服务器,来伺候资源文件。然后这个http服务器和客户端使用了websocket通讯协议,当原始文件作出改动后,webpack-dev-server就会实时编译,然后将最后编译文件实时渲染到页面上。

webpack-dev-server配置

webpack.config.js中,有一个devServer选项是用来配置webpack-dev-server,这里简单讲几个比较常用的配置。

port

我们可以通过port来设置服务器端口号。

module.exports = {
  
    ...
  
    // 配置webpack-dev-server
    devServer: {
        port: 8888,  // 自定义端口号
    },
};

open

devServer中有一个open选项,默认是为false,当你设置为true的时候,你每次运行webpack-dev-server就会自动帮你打开浏览器。

module.exports = {
  
    ...
  
    // 配置webpack-dev-server
    devServer: {
        open: true,   // 自动打开浏览器窗口
    },
};

proxy

这个选项是用来设置本地开发的跨域代理的,关于跨域的知识就不多在这说了,这里就说说如何去配置。

proxy的值必须是一个对象,在里面我们可以配置一个或多个跨域代理。最简单的配置写法就是地址配上api地址。

module.exports = {
  
    ...
  
    devServer: {
      	// 跨域代理
        proxy: {
          '/api': 'http://localhost:3000'
        },
    },
};

这时候,当你请求/api/users的时候,就会代理到http://localhost:3000/api/users

如果你不需要传递/api的话,你就需要使用对象的写法,从而增加一些配置选项:

module.exports = {
    //...
    devServer: {
      	// 跨域代理
        proxy: {
            '/api': {
              target: 'http://localhost:3000',  // 代理地址
              pathRewrite: { '^/api': '' },   // 重写路径
            },
        },
    },
};

这时候,当你请求/api/users的时候,就会代理到http://localhost:3000/users

在proxy中的选项,还有两个比较常用的,一个就是changeOrigin,默认情况下,代理时会保留主机头的来源,当我们将其设置为true可以覆盖这种行为;还有一个是secure选项,当你的接口使用了https的时候,需要将其设置为false

module.exports = {
    //...
    devServer: {
      	// 跨域代理
        proxy: {
            '/api': {
              target: 'http://localhost:3000',  // 代理地址
              pathRewrite: { '^/api': '' },   // 重写路径
              secure: false,  // 使用https
              changeOrigin: true   // 覆盖主机源
            },
        },
    },
};

CSS处理

接下来讲讲关于webpackcss的解析处理叭。

解析CSS文件

在前面的例子也能看到,我们解析css需要用到的loadercss-loaderstyle-loadercss-loader主要用来解析css文件,而style-loader是将css渲染到DOM节点上。

首先我们来安装一下:

 # css-loader -> 6.2.0;  style-loader -> 3.2.1
 yarn add css-loader style-loader -D

然后我们新建一个css文件。

/* style.css */
body {
  background: #222;
  color: #fff;
}

然后在index.js引入一下:

import "./style.css";

紧接着我们配置一下webpack

module.exports = {
   ...
  
  module: {
    rules: [
      {
        test: /\.css$/,  // 识别css文件
        use: ['style-loader', 'css-loader']  // 先使用css-loader,再使用style-loader
      }
    ]
  },
  
   ...
};

这时候我们打包一下,会发现dist路径下只有main.jsindex.html。但打开一下index.html会发现css是生效的。

demo1.png

这是因为style-loader是将css代码插入到了main.js当中去了。

打包css文件

如果我们不想将css代码放进js中,而是直接导出一份css文件的话,就得使用另一个插件——mini-css-extract-plugin

# 2.1.0
yarn add mini-css-extract-plugin -D

然后将其引入到配置文件,并且在plugins引入。

const miniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
    ...
  
    plugins: [
      	// 使用miniCssExtractPlugin插件
        new miniCssExtractPlugin({
          	filename: "[name].css"  // 设置导出css名称,[name]占位符对应chunkName
        })
    ]
};

紧接着,我们还需要更改一下loader,我们不再使用style-loader,而是使用miniCssExtractPlugin提供的loader

const miniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
    ...
  
    module: {
        rules: [
            {
                test: /\.css$/,
              	// 使用miniCssExtractPlugin.loader替换style-loader
                use: [miniCssExtractPlugin.loader,'css-loader']
            }
        ]
    },
    plugins: [
        new miniCssExtractPlugin({
          	filename: "[name].css" 
        })
    ]
};

接下来打包一下,dist路径下就会多出一个main.css文件,并且在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>Document</title>
<script defer src="main.js"></script><link href="main.css" rel="stylesheet"></head>
<body>
    HelloWorld!
</body>
</html>

css添加浏览器前缀

当我们使用一下css新特性的时候,可能需要考虑到浏览器兼容的问题,这时候可能需要对一些css属性添加浏览器前缀。而这类工作,其实可以交给webpack去实现。准确来说,是交给postcss去实现。

postcss对于css犹如babel对于JavaScript,它专注于对转换css,比如添加前缀兼容、压缩css代码等等。

首先我们需要先安装一下postcsspost-css-loader

# postcss -> 8.3.6,postcss-loader -> 6.1.1
yarn add postcss postcss-loader -D

接下来,我们在webpack配置文件先引入postcss-loader,它的顺序是在css-loader之前执行的。

rules: [
  {
    test: /\.css$/,
    // 引入postcss-loader
    use: [miniCssExtractPlugin.loader, 'css-loader', 'postcss-loader']
  }
]

接下来配置postcss的工作,就不在webpack的配置文件里面了。postcss自身也是有配置文件的,我们需要在项目根路径下新建一个postcss,config.js。然后里面也有一个配置项,为plugins

module.exports = {
    plugins: []
}

这也意味着,postcss自身也支持很多第三方插件使用。

现在我们想实现的添加前缀的功能,需要安装的插件叫autoprefixer

# 1.22.10
yarn add autoprefixer -D

然后我们只需要引入到postcss的配置文件中,并且它里面会有一个配置选项,叫overrideBrowserslist,是用来填写适用浏览器的版本。

module.exports = {
    plugins: [
        // 将css编译为适应于多版本浏览器
        require('autoprefixer')({
            // 覆盖浏览器版本
          	// last 2 versions: 兼容各个浏览器最新的两个版本
          	// > 1%: 浏览器全球使用占有率大于1%
            overrideBrowserslist: ['last 2 versions', '> 1%']
        })
    ]
}

关于overrideBrowserslist的选项填写,我们可以去参考一下browserslist,这里就不多讲。

当然,我们其实可以在package.json中填写兼容浏览器信息,或者使用browserslist配置文件.browserslistrc来填写,这样子如果我们以后使用其他插件也需要考虑到兼容浏览器的时候,就可以统一用到,比如说babel

// package.json 文件
{
  ...
  "browserslist": ['last 2 versions', '> 1%']
}

# .browserslsetrc 文件
last 2 versions
> 1%

但如果你多个地方都配置的话,overrideBrowserslist的优先级是最高的。

接下来,我们修改一下style.css,使用一下比较新的特性。

body {
    display: flex;
}

然后打包一下,看看打包出来后的main.css

body {
    display: -webkit-box;
    display: -ms-flexbox;
    display: flex;
}

压缩css代码

当我们需要压缩css代码的时候,可以使用postcss另一个插件——cssnano

# 5.0.7
yarn add cssnano -D

然后还是在postcss配置文件中引入:

module.exports = {
    plugins: [
        ... ,
        require('cssnano')
    ]
}

打包一下,看看main.css

body{display:-webkit-box;display:-ms-flexbox;display:flex}

解析CSS预处理器

在现在我们实际开发中,我们会更多使用SassLess或者stylus这类css预处理器。而其实html是无法直接解析这类文件的,因此我们需要使用对应的loader将其转换成css

接下来,我就以sass为例,来讲一下如何使用webpack解析sass

首先我们需要安装一下sasssass-loader

# sass -> 1.36.0, sass-loader -> 12.1.0
yarn add sass sass-loader -D

然后我们在module加上sass的匹配规则,sass-loader的执行顺序应该是排第一,我们需要先将其转换成css,然后才能执行后续的操作。

rules: [
  ...
  
  {
    test: /\.(scss|sass)$/,
    use: [miniCssExtractPlugin.loader, 'css-loader', 'postcss-loader', 'sass-loader']
  }
]

然后我们在项目中新建一个style.scss

$color-white: #fff;
$color-black: #222;

body {
    background: $color-black;

    div {
        color: $color-white;
    }
}

然后在index.js引入。

import "./style.css";
import "./style.scss";

然后执行打包,再看看打包出来的main.cssscss文件内容被解析到里面,同时如果我们引入多个csscss预处理器文件的话,miniCssExtractPlugin也会将其打包成一个bundle文件里面。

body{display:-webkit-box;display:-ms-flexbox;display:flex}
body{background:#222}body div{color:#fff}

其他静态资源处理

当我们使用了图片、视频或字体等等其他静态资源的话,我们需要用到url-loaderfile-loader

# url-loader -> 4.1.1; file-loader -> 6.2.0
yarn add url-loader file-loader -D

首先我们在项目中引入一张图片,然后在引入到index.js中。

import pic from "./image.png";

const img = new Image();
img.src= pic;
document.querySelector('body').append(img);

然后我先使用url-loader

module.exports = {
  ...
  
  module: {
    rules: [
      {
        test: /\.(png|je?pg|gif|webp)$/,
        use: ['url-loader']
      }
    ]
  }
};

然后执行一下打包。

你会发现,dist路径下没有图片文件,但是你打开页面是可以看到图片的,且通过调试工具,我们可以看到其实url-loader默认会将静态资源转成base64

demo2.png

当然,url-loader选项有提供一个属性,叫limit,就是我们可以设置一个文件大小阈值,当文件大小超过这个值的时候,url-loader就不会转成base64,而是直接打包成文件。

module.exports = {
  ...
  
  module: {
    rules: [
      {
        test: /\.(png|je?pg|gif|webp)$/,
        use: [{
          loader: 'url-loader',
          options: {
            name: '[name].[ext]',   // 使用占位符设置导出名称
            limit: 1024 * 10  // 设置转成base64阈值,大于10k不转成base64
          }
        }]
      }
    ]
  }
};

这时候我们再打包一下,dist文件夹下就会出现了图片文件。

file-loader其实跟url-loader差不多,但它默认就是导出文件,而不会导出base64的。

module.exports = {
  ...
  
  module: {
    rules: [
      {
        test: /\.(png|je?pg|gif|webp)$/,
        use: ['file-loader']
      }
    ]
  }
};

打包一下,会发现dist文件夹下依旧会打包成一个图片文件,但是它的名称会被改成哈希值,我们可以通过options选项来设置导出的名称。

module.exports = {
  ...
  
  module: {
    rules: [
      {
        test: /\.(png|je?pg|gif|webp)$/,
        use: [{
          loader: 'file-loader',
          options: {
            name: '[name].[ext]',   // 使用占位符设置导出名称
          }
        }]
      }
    ]
  }
};

而对于视频文件、字体文件,也是用相同的方法,只不过是修改test

module.exports = {
  ...
  module: {
    rules: [
      // 图片
      {
        test: /\.(png|je?pg|gif|webp)$/,
        use: {
          loader: 'url-loader',
          options: {
            esModule: false,
            name: '[name].[ext]',
            limit: 1024 * 10
          }
        }
      },
      // 字体
      {
        test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
        use: {
          loader: 'url-loader',
          options: {
            name: '[name].[ext]',
            limit: 1024 * 10
          }
        }
      },
      // 媒体文件
      {
        test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
        use: {
          loader: 'url-loader',
          options: {
            name: '[name].[ext]',
            limit: 1024 * 10
          }
        }
      }
    ]
  }
};

但现在有个问题,就是如果直接在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>Document</title>
</head>
<body>
    <img src="./image.png">
</body>
</html>

然后执行打包后,打包出来的index.html照样是<img src="./image.png">,但是我们并没有解析和打包出来image.png出来。

这时候我们需要借助另一个插件——html-withimg-loader

# 0.1.16
yarn add html-withimg-loader -D

然后我们再添加一条rules

{ test: /\.html$/,loader: 'html-withimg-loader' }

这时候打包成功后,dist文件成功将图片打包出来了,但是打开页面的时候,图片还是展示不出来。然后通过调试工具看的话,会发现

<img src="{"default":"image.png"}">

这是因为html-loader使用的是commonjs进行解析的,而url-loader默认是使用esmodule解析的。因此我们需要设置一下url-loader

{
  test: /\.(png|je?pg|gif|webp)$/,
    use: {
      loader: 'url-loader',
        options: {
          esModule: false,  // 不适用esmodule解析
          name: '[name].[ext]',
          limit: 1024 * 10
        }
    }
}

这时候重新打包一下,页面就能成功展示图片了。

Webpack5 资源模块

webpack.docschina.org/guides/asse…

webpack5中,新添了一个资源模块,它允许使用资源文件(字体,图标等)而无需配置额外 loader,具体的内容大家可以看看文档,这里简单讲一下如何操作。

前面的例子,我们对静态资源都使用了url-loader或者file-loader,而在webpack5,我们甚至可以需要手动去安装和使用这两个loader,而直接设置一个type属性。

{
  test: /\.(png|jpe?g|gif|svg|eot|ttf|woff|woff2)$/i,
  type: "asset/resource",
}

然后打包测试后,静态文件都会直接打包成文件并自动引入,效果跟file-loader一致。

type值提供了四个选项:

  • asset/resource 发送一个单独的文件并导出 URL。之前通过使用 file-loader 实现。
  • asset/inline 导出一个资源的 data URI。之前通过使用 url-loader 实现。
  • **asset/source :**导出资源的源代码。之前通过使用 raw-loader 实现。
  • asset 在导出一个 data URI 和发送一个单独的文件之间自动选择。之前通过使用 url-loader,并且配置资源体积限制实现。

同时,我们可以在output设置输出bundle静态文件的名称:

output: {
  path: path.resolve(__dirname, 'dist/'),
  filename: '[name].js',
  // 设置静态bundle文件的名称
  assetModuleFilename: '[name][ext]'
}

JavaScript转义

不仅仅css需要转义,JavaScript也要为了兼容多浏览器进行转义,因此我们需要用到babel

# 8.2.2
yarn add babel-loader -D

同时,我们需要使用babel中用于JavaScript兼容的插件:

# @babel/preset-env -> 7.14.9; @babel/core -> 7.14.8; @core-js -> 3.16.0
yarn add @babel/preset-env @babel/core core-js -D

接下来,我们需要配置一下webpack的配置文件。

{
  test: /\.js$/,
  use: ['babel-loader'] 
}

然后我们需要配置一下babel。当然我们可以直接在webpack.config.js里面配置,但是babel同样也提供了配置文件.babelrc,因此我们就直接在这边进行配置。

在根路径新建一个.babelrc

{
  "presets": [
    [
      "@babel/preset-env",
      {
      	// 浏览器版本
        "targets": {
          "edge": "17",
          "chrome": "67"
        },
         // 配置corejs版本,但需要额外安装corejs
        "corejs": 3,
        // 加载情况
        // entry: 需要在入口文件进入@babel/polyfill,然后babel根据使用情况按需载入
        // usage: 无需引入,自动按需加载
        // false: 入口文件引入,全部载入
        "useBuiltIns": "usage"
      }
    ]
  ]
}

接下来,我们来测试一下,先修改一下index.js

new Promise(resolve => {
    resolve('HelloWorld')
}).then(res => {
    console.log(res);
})

然后执行yarn build进行打包。

在使用babel之前,打包出来的main.js如下。

!function(){"use strict";new Promise((o=>{o("HelloWorld")})).then((o=>{console.log(o)}))}();

上面打包代码是直接使用了Promise,而没有考虑到低版本浏览器的兼容。然后我们打开babel后,执行一下打包命令,会发现代码多出了很多。

而在打包代码中,可以看到webpack使用了polyfill实现promise类,然后再去调用,从而兼容了低版本浏览器没有promise属性问题。

文件归类

在目前我们的测试代码中,我们的src文件夹如下:

├── src
│   ├── Alata-Regular.ttf
│   ├── image.png
│   ├── index.html
│   ├── index.js
│   ├── style.css
│   └── style.scss

而正常项目的话,我们会使用文件夹将其分好类,这并不难,我们先简单归类一下。

├── src
│   ├── index.html
│   ├── js
│   │   └── index.js
│   ├── static
│   │   └── image.png
│   │   └── Alata-Regular.ttf
│   └── style
│       ├── style.css
│       └── style.scss

接下来,我们需要打包出来的文件也是归类好的,这里就不太复杂,直接用一个assets文件夹将所有静态文件放进去,然后index.html放外面。

├── dist
│   ├── assets
│   │   ├── Alata-Regular.ttf
│   │   ├── image.png
│   │   ├── main.css
│   │   └── main.js
│   └── index.html

这里先补充一下style.css引入字体的代码:

@font-face {
    font-family: "test-font";
    src: url("../static/Alata-Regular.ttf") format('truetype')
}

body {
    display: flex;
    font-family: "test-font";
}

首先,我们先将打包出来的JavaScript文件放入assets文件夹下,我们只需要修改output.filename即可。

output: {
  path: path.resolve(__dirname, 'dist/'),
  filename: 'assets/[name].js'
}

其次,我们将打包出来的css文件也放入assets路径下,因为我们打包css是使用miniCssExtractPlugin,因此我们只需要配置一下miniCssExtractPluginfilename即可:

plugins: [
  ...
  new miniCssExtractPlugin({
    filename: "assets/[name].css"
  })
]

最后就是静态资源了,这里我们使用静态模块方案,所以直接修改output.assetModuleFilename即可:

output: {
  path: path.resolve(__dirname, 'dist/'),
  filename: 'assets/[name].js',
  assetModuleFilename: 'assets/[name][ext]'
},

这时候打包一下,预览一下页面,发现都正常引入和使用。

哈希值

通常,我们打包文件的文件名都需要带上一个哈希值,这会给我们的好处就是避免缓存。

webpack也提供了三种哈希值的策略,接下来我们一一来看看:

前期准备

为了更好的比较三者之间的区别,这边先调整一下项目和配置。

// index.js
import pic from "../static/image.png";

const img = new Image();
img.src = pic;
document.querySelector('body').append(img);

// main.js
import "../style/style.scss";
import "../style/style.css";

console.log('Hello World')


// webpack.config.js
entry: {
  index: './src/js/index.js',
  main: './src/js/main.js'
},

hash策略

hash策略,是以项目为单位的,也就是说,只要项目一个文件发生改动,首先打包后该文件对应的bundle文件名会改变,其次所有js文件和css文件的文件名也会改变。

我们先通过一个例子来看看:

首先我们需要在所有设置filename的地方加入[hash]占位符,同时我们也可以设置哈希值的长度,只需加上冒号和长度值即可,比如[hash:6]

module.exports = {
    entry: {
        index: './src/js/index.js',
        main: './src/js/main.js'
    },
    output: {
        path: path.resolve(__dirname, 'dist/'),
        filename: 'assets/[name]-[hash:6].js',
        assetModuleFilename: 'assets/[name]-[hash:6][ext]'
    },
    module: {
        ...
    },
    plugins: [
        ...
        new miniCssExtractPlugin({
            filename: "assets/[name]-[hash:6].css"
        }),
    ]
};

这时候打包一下,看看打包文件:

├── assets
│   ├── Alata-Regular-e83420.ttf
│   ├── image-7503bc.png
│   ├── index-7fa71a.js
│   ├── main-7fa71a.css
│   └── main-7fa71a.js
└── index.html

然后我随便改一下style.css,再重新打包一下。

这时候会发现index.jsmain.jsmain.css的文件名都会发生改变,但静态文件并不会发生变化。

├── assets
│   ├── Alata-Regular-e83420.ttf
│   ├── image-7503bc.png
│   ├── index-4b2329.js
│   ├── main-4b2329.css
│   └── main-4b2329.js
└── index.html

然后我们重新找一张图片,覆盖一下image.png,然后重新打包。

这时候,index.jsmain.jsmain.css的文件名依旧会发生改变,同时image.png也发生了变化。

├── assets
│   ├── Alata-Regular-e83420.ttf
│   ├── image-f3f2ec.png
│   ├── index-46acaa.js
│   ├── main-46acaa.css
│   └── main-46acaa.js
└── index.html

通过上面的例子,我们可以简单总结出:

  • 如果修改项目文件的话,所有的jscss打包文件的文件名都会发生变化,尽管来自多个chunk
  • 如果修改静态文件的话,该静态文件的打包文件文件名会发生变化,并且所有的jscss打包文件的文件名也都会发生变化。

chunkhash策略

chunkhash策略的话,是以chunk为单位的,如果一个文件发生变化,只有那条chunk相关的文件的打包文件文件名才会发生变化。

我们依旧通过例子看看:

首先我们先将配置文件都改成chunkhash。这里注意的是chunkhash不适用于静态文件,因此静态文件依旧使用hash

module.exports = {
    entry: {
        index: './src/js/index.js',
        main: './src/js/main.js'
    },
    output: {
        path: path.resolve(__dirname, 'dist/'),
        filename: 'assets/[name]-[chunkhash:6].js',
        assetModuleFilename: 'assets/[name]-[hash:6][ext]'
    },
    module: {
        ...
    },
    plugins: [
        ...
        new miniCssExtractPlugin({
            filename: "assets/[name]-[chunkhash:6].css"
        }),
    ]
};

先打包一次:

├── assets
│   ├── Alata-Regular-e83420.ttf
│   ├── image-f3f2ec.png
│   ├── index-6be98e.js
│   ├── main-a15a74.css
│   └── main-a15a74.js
└── index.html

然后我们首先修改一下style.css,打包一下,会发现main.cssmain.js都发生了变化,而index.js不是一个chunk的,因此不会发生变化。

├── assets
│   ├── Alata-Regular-e83420.ttf
│   ├── image-f3f2ec.png
│   ├── index-6be98e.js
│   ├── main-88f8ea.css
│   └── main-88f8ea.js
└── index.html

同样,我们再覆盖一下image.png,再打包一下。

这时候image.png固然会发生变化,然后index.js也发生了变化,因为它们是一个chunk的,而main.cssmain.js就不会发生变化。

├── assets
│   ├── Alata-Regular-e83420.ttf
│   ├── image-7503bc.png
│   ├── index-89dfd4.js
│   ├── main-88f8ea.css
│   └── main-88f8ea.js
└── index.html

简单总结一下:

  • 如果修改项目文件的话,该项目文件对应的chunkjscss打包文件的文件名都会发生变化。
  • 如果修改静态文件的话,该静态文件的打包文件文件名会发生变化,并且引入该静态文件对应的chunkjscss打包文件的文件名也都会发生变化。

contenthash策略

最后一个就是contenthash策略, 它是以自身内容为单位的,因此当一个文件发生变化的时候,首先它本身的打包文件的名称会发生变化,其次,引入它的文件的打包文件也会发生变化。

惯例来个实验:

我们将所以哈希占位符改成contenthash

module.exports = {
    entry: {
        index: './src/js/index.js',
        main: './src/js/main.js'
    },
    output: {
        path: path.resolve(__dirname, 'dist/'),
        filename: 'assets/[name]-[contenthash:6].js',
        assetModuleFilename: 'assets/[name]-[contenthash:6][ext]'
    },
    module: {
        ...
    },
    plugins: [
        ...
        new miniCssExtractPlugin({
            filename: "assets/[name]-[contenthash:6].css"
        }),
    ]
};

然后先打包一下。

├── assets
│   ├── Alata-Regular-e83420.ttf
│   ├── image-7503bc.png
│   ├── index-1e2b37.js
│   ├── main-02a4b4.css
│   └── main-c437b0.js
└── index.html

首先我们先修改一下图片吧,找一张新图覆盖一下image.png,然后打包一下。

首先image.png的名称一定会发生变化,因为它改动了。其次index.js也会发生变化,这是因为它引入了image.png,而image.png的名称发生变化,因此它代码中引入的名称也得发生变化,因此index.js的名称也会发生变化。

main.jsmain.css因为没有引用image.png,因此不会发生变化。

├── assets
│   ├── Alata-Regular-e83420.ttf
│   ├── image-f3f2ec.png
│   ├── index-e241d6.js
│   ├── main-02a4b4.css
│   └── main-c437b0.js
└── index.html

接下来,我们来修改一下main.js,然后打包一下。

我们会发现,只有main.js的打包文件会发生变化,而处于同个chunkmain.css却不会发生变化,这是因为main.css没有引用main.js

├── assets
│   ├── Alata-Regular-e83420.ttf
│   ├── image-f3f2ec.png
│   ├── index-e241d6.js
│   ├── main-02a4b4.css
│   └── main-d1f8ed.js
└── index.html

现在可以简单总结一下:

  • 不管是修改项目文件还是静态文件,它本身的打包文件的文件名会发生变化,其次引用该文件的对应打包文件的文件名也会发生变化,向上递归。

多个打包配置

通常我们项目都会有开发环境和生产环境。

前面我们也看到了webpack提供了一个mode选项,但我们开发中不太可能说开发的时候mode设置为development,然后等到要打包才设置为production。当然,前面我们也说了,我们可以通过命令--mode来对应匹配mode选项。

但如果开发环境和生产环境的webpack配置差异不仅仅只有mode选项的话,我们可能需要考虑多份打包配置了。

多个webpack配置文件

我们默认的webpack配置文件名为webpack.config.js,而webpack执行的时候,也默认会找该配置文件。

但如果我们不使用该文件名,而改成webpack.conf.js的话,webpack正常执行是会使用默认配置的,因此我们需要使用一个--config选项,来指定配置文件。

webpack --config webpack.conf.js

因此,我们就可以分别配置一个开发环境的配置webpack.dev.js和生成环境的配置webpack.prod.js,然后通过指令进行执行不同配置文件:

// package.json
 "scripts": {
   "dev": "webpack --config webpack.dev.js",
   "build": "webpack --config webpack.prod.js",
 }

单个配置文件

如果说,你不想创建那么多配置文件的话,我们也可以只只用webpack.config.js来实现多份打包配置。

按照前面说的使用--mode配置mode选项,其实我们可以在webpack.config.js中拿到这个变量,因此我们就可以通过这个变量去返回不同的配置文件。

// argv.mode可以获取到配置的mode选项
module.exports = (env, argv) => {
  if (argv.mode === 'development') {
    // 返回开发环境的配置选项
    return { ... }
  }else if (argv.mode === 'production') {
    // 返回生产环境的配置选项
    return { ... }
  }
};

优化一下Webpack配置

合理的配置mode选项和devtool选项

前面已经有讲到关于mode选项和devtool选项,而不同选项打包的速度也会有所不同,因此按照你的实际需求进行配置,有需要用到才生成,没需要用到就能省就省。

缩小文件搜索范围

alias选项

在配置文件中,其实有一个resovle.alias选项,它可以创建importreuquire别名,来确保模块引入变得更简单,同时webpack在打包的时候也能更快的找到引入文件。

// webpack.config.js
const path = require('path');

module.exports = {
  ...
  
  resolve: {
    alias: {
      // 配置style路径的别名
      style: path.resolve(__dirname, 'src/style/')
    },
  }
};
// 使用
import "style/style.scss";
import "style/style.css";

include、exclude选项

当我们使用loader的时候,我们可以配置include来指定只解析该路径下的对应文件,同时我们可以配置exclude来指定不解析该路径下的对应文件。

const path = require('path');

module.exports = {
  ...
  
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [miniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'],
        include: [path.resolve(__dirname, 'src')]  // 只解析src路径下的css
      }
      {
        test: /\.js$/,
        use: 'babel-loader',
        exclude: /node_modules/   // 不解析node_modules路径下的js
      }
  ]
}
};

noParse选项

我们可以在module.noParse选项中,只配置不需要解析的文件。通常我们会忽略一些大型插件从而来提高构建性能。

module.exports = {
  ...
  module: {
    noParse: /jquery|lodash/,
  },
};

使用HappyPack开启多进程Loader

webpack构建过程中,其实大部分消耗时间都是用到loader解析上面,一方面是因为转换文件数据量很大,另一方面是因为JavaScript单线程特性的原因,因此需要一个个去处理,而不能并发操作。

而我们可以使用HappyPack,将这部分任务分解到多个子进程中去进行并行处理,子进程处理完成后把结果发送到主进程中去,从而减少总的构建时间。

github.com/amireh/happ…

# 5.0.1
yarn add happypack -D
// webpack.config.js
const HappyPack = require("happypack");
const os = require("os");
const HappyThreadPool = HappyPack.ThreadPool({size: os.cpus().length});

module.exports = {
  ...
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [{
          loader: 'happypack/loader?id=happyBabelLoader'
        }]
      }
    ]
  },
  plugins: [
    new HappyPack({
      id: 'happyBabelLoader',  // 与loader对应的id标识
      // 用法跟loader配置一样
      loaders: [
        {loader: 'babel-loader', options: {}}
      ],
      threadPool: HappyThreadPool  // 共享进程池
    })
  ]
};

使用webpack-parallel-uglify-plugin 增强代码压缩

起码有聊到,当modeproduction的时候,webpack打包会开启代码压缩插件,同时webpack也有提供一个optimization选项,让我们可以使用自己喜欢的插件去覆盖原生插件。

因此,我们可以使用webpack-parallel-uglify-plugin来覆盖原生代码压缩插件,它的一个优点就是可以并行执行。

github.com/gdborton/we…

# 2.0.0
yarn add webpack-parallel-uglify-plugin -D
// webpack.config.js
const ParallelUglifyPlugin = require("webpack-parallel-uglify-plugin")

module.exports = {
  ...
  
  optimization: {
    minimizer: [
      new ParallelUglifyPlugin({
        // 缓存路径
        cacheDir: '.cache/',  
        // 压缩配置
        uglifyJS: {
          output: {
            comments: false,
            beautify: false
          },
          compress: {
            drop_console: true,
            collapse_vars: true,
            reduce_vars: true
          }
        }
      })
    ]
  }
};

配置缓存

我们每次执行构建都会把所有的文件都重新编译一边,如果我们可以将这些重复动作缓存下来的话,对下一步的构建速度会有很大的帮助。

现在大部分的loader都提供了缓存选项,但并非所有的loader都有,因此我们最好自己去配置一下全局的缓存动作。

Webpack5之前,我们都使用了cache-loader,而在webpack5中,官方提供了一个cache选项给我们带来持久性缓存。

// 开发环境
module.exports = {
  cache: {
    type: 'memory'  // 默认配置
  }
}

// 生产环境
module.exports = {
  cache: {
    type: 'filesystem',
    buildDependencies: {
      config: [__filename]
    }
  }
}

分析打包文件大小

我们可以使用webpack-bundle-analyzer插件来帮助我们分析打包文件,它会将打包后的内容束展示为方便交互的直观树状图,让我们知道我们所构建包中真正引入的内容。

github.com/webpack-con…

# 4.4.2
yarn add webpack-bundle-analyzer -D
// webpack.config.js
const {BundleAnalyzerPlugin} = require('webpack-bundle-analyzer');

module.exports = {
    ...
  
    plugins: [
        new BundleAnalyzerPlugin()
    ]
};

然后我们打包后,webpack会自动打开一个页面,显示我们打包文件的情况,通过打包报告可以很直观的知道哪些依赖包大,则可以做做针对性的修改。

demo3.png

如果不想每次运行都打开网页的话,我们可以先将数据保存起来,然后要看的时候再执行新的命令去查看。

// webpack.config.js
new BundleAnalyzerPlugin({
   analyzerMode: 'disabled',
   generateStatsFile: true
 })
// package.json
"scripts": {
  "analyzer": "webpack-bundle-analyzer --port 3000 ./dist/stats.json"
},

手写一下

手写Loader

webpack.js.org/contribute/…

webpack官网,它给提出了几个loader的编写原则:

  • **单一原则:**每个loader只做一件事情;
  • 链式调用:webpack会按照顺序链式去调用每个loader
  • **统一原则:**遵循webpack定制的设计规则和结构,输入和输入均为字符串,每个loader完全独立,即插即用。

同时webpack还给我们提供了loader API,因此我们可以使用this去获取需要用到的API,但也是因为如此,我们loader的实现就不能使用箭头函数了。

今天,我们来简单手写一下sass-loadercss-loaderstyle-loader,而它们也有各自的单一功能:

  • sass-loader:用来解析sassscss代码;
  • css-loader:用来解析css代码;
  • style-loader:将css代码插入到js中。

首先,我们先创建一个myLoders文件夹,然后创建三个loader文件。

├── myLoaders
│   ├── ou-css-loader.js
│   ├── ou-sass-loader.js
│   └── ou-style-loader.js

然后我们需要在webpack引入,并且需要配置一下resolveLoader选项,因为webpack默认只会去node_modules搜索loader

module.exports = {
  ...

  resolveLoader: {
    // 添加loader查询路径
    modules: ['node_modules', './myLoaders']
  },
  module: {
    rules: [{
      test: /\.(scss|sass)$/,
      // 使用自己的loader
      use: ['ou-style-loader','ou-css-loader','ou-sass-loader']
    }]
  }
};

首先我们先来实现ou-sass-loader

loader的本质就是一个函数,而我们可以在函数的第一个参数获取到对应文件的代码,我们可以先打印一下来看看。

// ou-sass-loader.js
module.exports = function(source) {
  console.log(source);
}

然后执行打包后,我们可以看到我们的scss文件中的代码。

因此,我们可以使用sass插件来进行解析scss代码,sass有一个render函数可以去解析。

// ou-sass-loader.js
const sass = require('sass');

module.exports = function(source) {
  // 使用render函数进行解析scss代码
  sass.render({data: source},  (err, result) => {
    console.log(result);
  });
}

我们在执行一下打包,会发现result是一个对象,而里面的css就是我们所需要的,因此我们需要将其返回出去。

这里cssBuffer,我们需要去解析它,但是解析它是css-loader的工作,而不是sass-loader的工作。

{
  css: <Buffer 62 6f 64 79 20 7b 0a 20 20 62 61 63 6b 67 72 6f 75 6e 64 3a 20 23 32 32 32 3b 0a 7d 0a 62 6f 64 79 20 64 69 76 20 7b 0a 20 20 63 6f 6c 6f 72 3a 20 23 ... 6 more bytes>,
  map: null,
  stats: {
    entry: 'data',
    start: 1628131813793,
    end: 1628131813830,
    duration: 37,
    includedFiles: [ [Symbol($ti)]: [Rti] ]
  }
}

但这里是一个异步操作,我们不能直接return回去,而是需要使用到webpack提供的一个API——this.async,它本身是一个函数,然后会返回一个callback()让我们可以返回异步的结果。

// ou-sass-loader.js
const sass = require('sass');

module.exports = function(source) {
  // 获取callback函数
  const callback = this.async();
  sass.render({data: source},  (err, result) => {
    // 将结果返回
    if (err) return callback(err);
    callback(null, result.css);
  });
}

这时候,我们ou-sass-loader就实现了,接下来我们来实现ou-css-loader

它其实任务很简单,就是将ou-sass-loader返回的css解析为字符串就可以了。

// ou-css-loader.js
module.exports = function(source) {
    return JSON.stringify(source)
}

最后就是ou-style-loader,它的任务就是创建一个style标签,然后将ou-css-loader返回的数据插进去,并且将style标签放置到head标签里面去。

// ou-style-loader.js
module.exports = function(sources) {
    return `
        const tag = document.createElement("style");
        tag.innerHTML = ${sources};
        document.head.appendChild(tag)
    `
}

这时我们简易版的sass-loadercss-laoderstyle-laoder就实现了,我们可以执行一下打包命令,检验页面是不是有对应的样式效果。

手写Plugin

webpack.js.org/contribute/…

webpack运行过程中,会存在一个生命周期,而在生命周期中webpack会广播出许多事情,而在plugin中是可以监听到这些事件,因此plugin是可以实现在合适的时机通过Webpack提供的API去实现一些动作。

正常情况下,一个plugin是一个类,并且里面会有一个apply函数,而在apply函数中会接收到一个compiler参数,里面包含了关于webpack环境所有的配置信息。

module.exports = class MyPlugin {
  apply (compiler) {}
}

compiler中会暴露很多生命周期钩子函数,具体的可以查看文档。我们可以通过以下方式去访问钩子函数。

compiler.hooks.someHook.tap(...)

tap方法中,接收两个参数,一个是该plugin的名称,一个是回调函数,而在回调函数中,又会接收到一个compilation参数。

module.exports = class MyPlugin {
  apply (compiler) {
    compiler.hooks.compile.tap("MyPlugin", (compilation) => {
      console.log(compilation)
    })
  }
}

compilation对象包含了当前的模块资源、编译生成资源、变化的文件等。当运行webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用。

compliation也暴露了许多的钩子,具体的话可以去看看文档

接下来,简单实现一下一个plugin,打包后生成一个txt文件,里面会打印出每个bundle的大小。

module.exports = class MyPlugin {
  apply(compiler) {
    // 生成资源到 output 目录之前
    compiler.hooks.emit.tap("MyPlugin", (compilation) => {
      let str = ''
      for (let filename in compilation.assets) {
        // 获取文件名称和文件大小
        str += `${filename} -> ${compilation.assets[filename]['size']() / 1000}KB\n`
      }

      // 新建fileSize.txt
      compilation.assets['fileSize.txt'] = {
        // 内容
        source: function () {
          return str
        }
      }
    })
  }
}

紧接着,我们将其引入到webpack.config.js,并在plugins中创建实例。

const MyPlugin = require("./myPlugins/my-plugin")

module.exports = {
    ...
  
    plugins: [
        new MyPlugin()
    ]
};

然后打包后,dist文件中会生成一个fileSize.txt文件。

assets/Alata-Regular-e83420.ttf -> 96.208KB
assets/image-f3f2ec.png -> 207.392KB
index.html -> 0.364KB
assets/index-41f0e2.css -> 0.177KB
assets/index-acc2f5.js -> 1.298KB

手写Webpack

代码:github.com/ouduidui/mi…

喜欢的朋友可以点个Star哦~

初始化

首先我们先初始化我们的项目文件。

先新建一个src路径,然后创建三个js文件——index.jsa.jsb.js

// index.js
import {msg} from "./a.js";

console.log(msg);



// a.js
import {something} from "./b.js";

export const msg = `Hello ${something}`;


// b.js
export const something = 'World';

然后我们可以先安装webpack,然后测试一下打包出来的bundle文件有什么特点。

这里就不多说了,直接看bundle文件(默认配置,modedevelopment

打包后,我们可以看到bundle文件有很多内容,但也有一大半注释。

其实我们只需要看两个地方,一个是__webpack_modules__变量。我们可以看到它是一个对象,然后key值为module路径,而value值是执行module代码的函数。

var __webpack_modules__ = ({
  "./src/a.js": (() => eval( ... )),
  "./src/b.js": (() => eval( ... )),
  "./src/index.js": (() => eval( ... ))
})

其次,我们能看到一个函数,叫__webpack_require__,它接收一个moduleId的参数。然而我们可以在最后看到了这个函数的调用,就会发现其实moduleId就是__webpack_modules__key值,也就是module的路径。

var __webpack_exports__ = __webpack_require__("./src/index.js");

到这里,我们就可以大概捋清楚webpack打包的一个逻辑了。

  • webpack是直接拿到js文件的代码,即字符串。然后通过eval()函数执行代码;
  • webpack会从入口文件开始,不断递归遍历引入模块,然后保持在一个对象里面,key值为moduleId,即模块路径,而value是模块的相关代码。
  • webpack会将代码转换为commonJS,即使用require去引入模块,同时它自身会去封装一个require函数,去执行入口文件代码。

话不多说,我们开始来手写代码。

首先我们可以先初始化webpack配置文件——webpack.config.js

const path = require("path");

module.exports = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, "./dist"),
    filename: 'index.js'
  }
}

其次,我们新建一个lib文件夹,然后创建一个webpack.js,用来手写我们的mini-webpack

我们可以先初始化一下,Webpack是一个类,其次构建函数会接受配置文件,其次会有一个run函数,是webpack的运行函数。

module.exports = class Webpack {
  /**
  *  构造函数,获取webpack配置
  *  @param {*} options
  */
  constructor(options) {}

  /**
  *  webpack运行函数
  */
  run() {
    console.log('开始执行Webpack!')
  }
}

然后我们需要一个执行文件,即在根路径创建一个debugger.js

const webpack = require('./lib/webpack');
const options = require('./webpack.config');

new webpack(options).run();

紧接着我们执行一下该文件。

node debugger.js

这时候命令行就会打印出开始执行Webpack!

我们可以开始手写mini-webpack了。

模块解析

首先,在构造函数中,我们需要保存一下配置信息。

constructor(options) {
  const {entry, output} = options;
  this.entry = entry;  // 入口文件
  this.output = output;  // 导出配置
}

在执行的第一步,我们需要来解析一下入口文件,因此我们用一个parseModules来实现这个功能。

module.exports = class Webpack {
    constructor(options) {
        ...
    }

    run() {
        // 解析模块
        this.parseModules(this.entry);
    }

    /**
     *  模块解析
     *  @param {*} file
     */
    parseModules(file) {}
}

parseModules中,我们需要做两件事情:分析模块信息、递归遍历引入模块。

我们一步一步来实现。首先,封装一个getModuleInfo函数,来分析模块信息。

parseModules(file) {
  // 分析模块
  this.getModuleInfo(file);
}

 /**
 *  分析模块
 *  @param {*} file
 *  @returns Object
 */
getModuleInfo(file) {}

首先,我们接收到的file其实就是入口文件的相对路径,即./src/index.js。因此我们可以先用node自带的fs模块来读取文件内容。

getModuleInfo(file) {
  // 读取文件
  const body = fs.readFileSync(file, "utf-8");
}

读取到内容后,我们就要来分析一下文件内容了,这时候就需要用到了AST语法树了。

抽象语法树 (Abstract Syntax Tree),简称 AST,它是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。

演示地址:astexplorer.net/

这里我们用到的时候babelparse插件,通过它来将JavaScript转成AST

# 7.14.8
yarn add @babel/parser -D
const fs = require("fs");
const parser = require("@babel/parser");

module.exports = class Webpack {
    ...

    getModuleInfo(file) {
      // 读取文件
      const body = fs.readFileSync(file, "utf-8");

      // 转化为AST语法树
      const ast = parser.parse(body, {
        sourceType: 'module'  // 表示我们解析的是ES模块
      })
    }
}

紧接着,我们还需要使用@babel/traverse来遍历AST,从而来识别该文件有没有引入其他模块,有的话就将其记录下来。

# 7.14.8
yarn add @babel/traverse -D
const traverse = require("@babel/traverse").default;

traverse接受两个参数,第一个是ast语法树,第二个是一个对象,在对象中我们可以设置观察者函数,并且可以针对语法树中的特定节点类型。

比如我们这次只需要找到引入模块的语句,对应的节点类型为ImportDeclaration,我们就可以设置对应的ImportDeclaration函数,并在参数值获取到节点信息。

const fs = require("fs");
const path = require("path");
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;


module.exports = class Webpack {
    ...

    getModuleInfo(file) {
      // 读取文件
      const body = fs.readFileSync(file, "utf-8");

      // 转化为AST语法树
      const ast = parser.parse(body, {
        sourceType: 'module'  // 表示我们解析的是ES模块
      })
      
      traverse(ast, {
        // visitor函数
        ImportDeclaration({node}) {
          console.log(node);
        }
      })
    }
}

我们执行一下,可以打印出import {msg} from "./a.js"的语法树。

因此,我们需要将其路径收集起来。

// 依赖收集
const deps = {};
traverse(ast, {
  // visitor函数
  ImportDeclaration({node}) {
    // 入口文件路径
    const dirname = path.dirname(file);
    // 引入文件路径
    const absPath = "./" + path.join(dirname, node.source.value);
    deps[node.source.value] = absPath;
  }
})

此时的deps就是{ './a.js': './src/a.js' },之所以要保存它相对项目根路径的相对路径,是为了后面更好的去拿到它的文件内容。

收集完依赖后,我们需要将AST转回JavaScript代码,并且将其转成ES5语法。这时候我们就会用到@babel/core@babel/preset-env

# @babel/core -> 7.14.8, @babel/preset-env -> 7.14.8
yarn add @babel/core @babel/preset-env -D
const fs = require("fs");
const path = require("path");
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const babel = require("@babel/core");

module.exports = class Webpack {
    ...

    getModuleInfo(file) {
      // 读取文件
      const body = fs.readFileSync(file, "utf-8");

      // 转化为AST语法树
      const ast = parser.parse(body, {
        sourceType: 'module'  // 表示我们解析的是ES模块
      })
      
      // 依赖收集
      const deps = {};
      traverse(ast, {
        // visitor函数
        ImportDeclaration({node}) {
          // 入口文件路径
          const dirname = path.dirname(file);
          // 引入文件路径
          const absPath = "./" + path.join(dirname, node.source.value);
          deps[node.source.value] = absPath;
        }
      })

      // ES6转成ES5
      const {code} = babel.transformFromAst(ast, null, {
        presets: ["@babel/preset-env"],
      })
    }
}

这时候我们可以打印一下code,会发现它不再是ESModule的引入方式了,而是使用了CommonJS引入方式。

"use strict";

var _a = require("./a.js");

console.log(_a.msg);

最终,getModuleInfo会返回一个对象,对象里面包含着解析文件的路径,该文件的依赖对象以及文件代码。

parseModules(file) {
  // 分析模块
  const entry = this.getModuleInfo(file);
}

getModuleInfo(file) {
  ...

  return {
    file,   // 文件路径
    deps,  // 依赖对象
    code   // 代码
  };
}

但我们分析完入口文件后,我们就需要进行递归遍历,去分析引入模块。

首先,我们需要新建一个数组,保存一下所有的分析结果。其次,我们来实现一下getDeps函数,来递归遍历引入模块。

parseModules(file) {
  // 分析模块
  const entry = this.getModuleInfo(file);
  const temp = [entry];

  // 递归遍历,获取引入模块代码
  this.getDeps(temp, entry)
}


/**
 * 获取依赖
 *  @param {*} temp
 *  @param {*} module
*/
getDeps(temp, {deps}) {}

getDeps中,我们可以通过第二个参数获取到依赖对象,其次通过遍历这个对象,一一执行一下getModuleInfo函数,获取各个依赖模块的解析内容,并保存到temp

最后,再自调用一下getDeps,传入引入模块内容,继续递归遍历。

getDeps(temp, {deps}) {
  // 遍历依赖
  Object.keys(deps).forEach(key => {
    // 获取依赖模块代码
    const child = this.getModuleInfo(deps[key]);
    temp.push(child);
    // 递归遍历
    this.getDeps(temp, child);
  })
}

这里还需要进行查重,比如在多个文件都引入了b.js的话,temp数组就会保存多个b.js的内容对象,因此我们可以先查重一下,如果temp对象没有该模块,我们再执行后面的操作。

getDeps(temp, {deps}) {
  Object.keys(deps).forEach(key => {
    // 去重
    if (!temp.some(m => m.file === deps[key])) {
      const child = this.getModuleInfo(deps[key]);
      temp.push(child);
      this.getDeps(temp, child);
    }
  })
}

这时候,我们模块解析的操作已经完成了差不多了。

最后我们最需要将temp数组,转换成对象,即跟__webpack_modules__类似,以路径为key名,然后value为对应的内容信息。

parseModules(file) {
  const entry = this.getModuleInfo(file);
  const temp = [entry];

  this.getDeps(temp, entry)

  // 将temp转成对象
  const depsGraph = {};
  temp.forEach(moduleInfo => {
    depsGraph[moduleInfo.file] = {
      deps: moduleInfo.deps,
      code: moduleInfo.code
    }
  })

  return depsGraph;
}

这时候,我们在run()函数保存一下解析结果,就完成了第一步操作了。

run() {
  // 解析模块
  this.depsGraph = this.parseModules(this.entry);
}

打包

下一步就是执行打包操作了,我们先封装一个bundle函数。

run() {
  // 解析模块
  this.depsGraph = this.parseModules(this.entry);

  // 打包
  this.bundle()
}

/**
*  生成bundle文件
*/
bundle() { }

首先我们先把简单的部分完成了,就是生成打包文件。

我们要用到fs模块,先识别打包路径存不存在,不存在的话新建一个目录,其次就写入bundle文件。

bundle() {
  const content = `console.log('Hello World')`;

  // 生成bundle文件
  !fs.existsSync(this.output.path) && fs.mkdirSync(this.output.path);
  const filePath = path.join(this.output.path, this.output.filename);
  fs.writeFileSync(filePath, content);
}

这时运行一下打包命令,项目里就会出现一个dist文件夹,里面会有一个index.js

console.log('Hello World')

接下来我们就得来实现bundle文件的内容。

首先它是一个匿名函数只执行的方式,然后它接收一个参数__webpack_modules__,即我们前面解析文件的结果。

(function(__webpack_modules__){
  ...
})(this.depsGraph)

其次,我们需要是实现一下__webpack_require__函数,它接收一个moduleId参数,即路径参数。

然后我们还需要去调用一下__webpack_require__,并传入入口文件路径。

(function(__webpack_modules__){
  function __webpack_require__(moduleId) {
    ...
  }
  __webpack_require__(this.entry)  
})(this.depsGraph)

前面我们又看到,babel将代码转义成commonJS,因此我们需要来实现一下require函数,因为JavaScript本身不具备。

require函数的实质就是返回引入文件的内容。

同时,我们还需要新建一个exports对象,这样子模块导出的内容就可以保存到里面去了,最后也需要将其返回出去。

(function(__webpack_modules__){
  function __webpack_require__(moduleId) {
    // 实现require方法
    function require(relPath) {
      return __webpack_require__(__webpack_modules__[moduleId].deps[relPath])
    }
    // 保存导出模块
    var exports = {};
    
    return exports
  }
  __webpack_require__(this.entry)  
})(this.depsGraph)

最后,就只需要来执行一下入口文件的代码即可。

这里还是使用一个匿名函数并自调用。

(function(__webpack_modules__){
  function __webpack_require__(moduleId) {
    // 实现require方法
    function require(relPath) {
      return __webpack_require__(__webpack_modules__[moduleId].deps[relPath])
    }
    // 保存导出模块
    var exports = {};
    
    // 调用函数
    (function (require,exports,code) {
      eval(code)
    })(require,exports,__webpack_modules__[moduleId].code)
    
    return exports
  }
  __webpack_require__(this.entry)  
})(this.depsGraph)

这时候我们再将这段代码,换到content变量中去。

bundle() {
  const content = `
    (function (__webpack_modules__) {
      function __webpack_require__(moduleId) {
        function require(relPath) {
          return __webpack_require__(__webpack_modules__[moduleId].deps[relPath])
          }
          var exports = {};
          (function (require,exports,code) {
            eval(code)
          })(require,exports,__webpack_modules__[moduleId].code)
          return exports
        }
        __webpack_require__('${this.entry}')
    })(${JSON.stringify(this.depsGraph)})
  `;

  // 生成bundle文件
  !fs.existsSync(this.output.path) && fs.mkdirSync(this.output.path);
  const filePath = path.join(this.output.path, this.output.filename);
  fs.writeFileSync(filePath, content);
}

然后执行打包,就可以看到完整的打包内容了。

(function (__webpack_modules__) {
    function __webpack_require__(moduleId) {
        function require(relPath) {
            return __webpack_require__(__webpack_modules__[moduleId].deps[relPath])
        }
        var exports = {};
        (function (require,exports,code) {
            eval(code)
        })(require,exports,__webpack_modules__[moduleId].code)
        return exports
    }
    __webpack_require__('./src/index.js')
})({"./src/index.js":{"deps":{"./a.js":"./src/a.js"},"code":"\"use strict\";\n\nvar _a = require(\"./a.js\");\n\nconsole.log(_a.msg);"},"./src/a.js":{"deps":{"./b.js":"./src/b.js"},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports.msg = void 0;\n\nvar _b = require(\"./b.js\");\n\nvar msg = \"Hello \".concat(_b.something);\nexports.msg = msg;"},"./src/b.js":{"deps":{},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports.something = void 0;\nvar something = 'World';\nexports.something = something;"}})

最后,我们执行一下,看看能不能打印出Hello World

node ./dist/index.js