# webpack

# 如何在浏览器端实现模块化?

# 问题

  • 效率问题:精细的模块划分带来了更多的 js 文件,更多的 js 文件带来了更多的请求,降低了页面访问效率。
  • 兼容性问题:浏览器只支持 es6 模块化,不支持 CommonJS(第三方模块,例如:axios)
  • 工具问题:浏览器不直接支持 npm 下载的第三方包
  • (非业务类)工程问题......

根本原因是在 node 端,可以本地读取文件,效率比浏览器远程传输文件高的多。在浏览器端开发时态(devtime)和运行态(runtime)的侧重点不同。

  • 开发时态
    • 支持多模块标准 es6、CommonJS
    • 模块划分精细一点
    • 不考虑兼容性问题
  • 运行时态
    • 文件少一点(合并多个相同类型文件)
    • 体积小一点(代码别换行了,名称简写)
    • 代码内容乱一点(别人看不懂代码)
    • 执行效率问题

# 解决办法

  • webpack 构建工具
  • grunt gulp fis browserify ......

官网:https://www.webpackjs.com/

基于 nodeJs ,以开发时态的一个入口文件,分析模块的依赖关系(利用模块化导入语句 ),经过一系列的过程(压缩合并),最终生成运行时态的文件。

# 编译结果分析

# 模拟编译结果

// 全局变量污染
const modules = {
  "./src/a.js": function(module, exports, require) {
    // xxx;
    // module.exports = 'a'
    // exports.a = 'a'
    // require('./b'); => require('./src/b.js')
  },
  "./src/b.js": function() {
    // xxx;
  }
}

// 专门写一个函数来运行
(function (modules) {
  // 提供 require 函数, 相当于运行一个模块,并得到导出结果 moduleId 模块路径
  function require(moduleId){
    var func = modules[moduleId];
    // 构造一个 module
    var module = {
      exports: {}
    }
    // 运行模块
    fnc(module, module.exports, require);
    // 得到模块返回结果
    return module.exports;
  }
  // 执行入口模块
  require('./src/index.js');
})({
  "./src/a.js": function(module, exports, require) {
    // xxx;
    // module.exports = 'a'
    // exports.a = 'a'
    // require('./b'); => require('./src/b.js')
  },
  "./src/b.js": function(module, exports, require) {
    // xxx;
  }
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
  • 为什么使用 eval 函数?
    • 便于定位错误信息。简易版 source map 。

# 配置文件

  • webpack.config.js
  • 命令行中的--config来指定配置文件。
  • 通过 CommonJS 模块导出一个对象。

# 为什么这里不能使用 es6 导出配置文件呢?

  • 打包的过程中,是在 node 环境下,会读取配置文件内容,会运行一次配置文件,相当于const config = require('./webpack.config.js')。我们自己的代码在打包过程中是不运行的,所以支持多模块。

  • mode: "development" mode: "production"

# devtool 配置

  • source map

  • webpack 中的 source map

    • 希望看到源码中的错误,而不是打包后的文件中的错误。chrome 率先支持了 source map 。没有兼容性问题,因为是开发者调试用的。。。
    • 多一个配置文件,浏览器发现 source map ,会找这个配置文件,文件中记录了原始代码以及转化后的代码的对应关系。
    • 不应在生产环境使用,source map 文件比较大,不仅会导致额外的网络传输,还会暴露原始代码。要调试真实的生产环境的代码,也需要做一些规避网络传输和代码暴露的问题。

# 编译过程

  • 整个过程大致分为三个步骤:
    • 初始化
    • 编译
    • 输出

# 初始化

  • 此阶段,webpack 会将CLI 参数、配置文件、默认配置进行融合,形成一个最终的配置对象。
  • 对配置的处理过程是依托一个第三方库 yargs 完成的。
  • 此阶段相对比较简单,主要是为接下来的编译阶段做必要的准备。
  • 目前,可以简单的理解为,初始化阶段主要用于产生一个最终的配置。

# 编译

# 1.创建chunk

  • chunk 是 webpack 在内部构建过程中的一个概念,译为块,它表示通过某个入口找到的所有依赖的统称。

  • 根据入口模块(默认为./src/index.js)创建一个 chunk 。

  • 每个 chunk 都有至少两个属性:

    • name:默认为 main
    • id:唯一编号,开发环境和 name 相同,生产环境是一个数字,从0 开始

# 2.构建所有依赖模块

// ./src/index.js
console.log('index');
require('./a.js');
require('./b.js');

// ./src/a.js
require('./b.js');
console.log('a');
module.exports='a';

// ./src/b.js
console.log('b');
module.exports='b';
1
2
3
4
5
6
7
8
9
10
11
12
13
  • ./src/index.js 出发,dependencies 中保存 ['./src/a.js', './src/b.js'] , ['./src/b.js']
  • 替换依赖函数
// 模块id : ./src/index.js
// 转化后的代码
console.log('index');
__webpack_require__('./src/a.js');
__webpack_require__('./src/b.js');

// 模块id : ./src/a.js
// 转化后的代码
__webpack_require__('./b.js');
console.log('a');
module.exports='a';

// 模块id : ./src/b.js
// 转化后的代码
console.log('b');
module.exports='b';
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  • 保存生成后的列表

    AST在线测试工具:https://astexplorer.net/

  • 简图:

# 3.产生chunk assets

  • 在第二步完成后,chunk 中会产生一个模块列表,列表中包含了模块 id 和模块转换后的代码。

  • 接下来,webpack 会根据配置为 chunk 生成一个资源列表,即 chunk assets,资源列表可以理解为是生成到最终文件的文件名和文件内容。

    chunk hash 是根据所有 chunk assets 的内容生成的一个 hash 字符串。 hash:一种算法,具体有很多分类,特点是将一个任意长度的字符串转换为一个固定长度的字符串,而且可以保证原始内容不变,产生的 hash 字符串就不变。

  • 简图:

# 4.合并chunk assets

  • 将多个 chunk 的 assets 合并到一起,并产生一个总的 hash .

# 输出 emit

此步骤非常简单,webpack 将利用 node 中的 fs 模块,根据编译产生的总的 assets ,生成相应的文件。

# 总过程

# 补充

  • module:模块,分割的代码单元,webpack 中的模块可以是任何内容的文件,不仅限于 JS
  • chunk:webpack 内部构建模块的块,一个 chunk 中包含多个模块,这些模块是从入口模块通过依赖分析得来的
  • bundle:chunk 构建好模块后会生成 chunk 的资源清单,清单中的每一项就是一个 bundle,可以认为 bundle 就是最终生成的文件
  • chunkhash:chunk 生成的资源清单内容联合生成的 hash 值
  • chunkname:chunk 的名称,如果没有配置则使用 main
  • id:通常指 chunk 的唯一编号,如果在开发环境下构建,和chunkname 相同;如果是生产环境下构建,则使用一个从 0 开始的数字进行编号

# 入口和出口

  • 入口配置的是 chunk

# output

  • path 绝对路径,默认是 dist
  • filename 生成文件的规则
    • [name] chunkname
    • [hash] 生成的总 hash 值
    • [chunkhash:5] chunk 对应的 hash 值
    • [id] 开发环境是 name 生产环境是数字

# 最佳实践

# 一个页面一个 js

  • 源码结构:

    src
      - pageA
        - index.js
      - pageB
        - index.js
      - pageC
        - main.js   主功能
        - main2.js  额外功能
      - common 公共目录
        - ...
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
  • webpack:

    module.exports = {
      entry: {
        pageA: './src/pageA/index.js',
        pageB: './src/pageB/index.js'
        pageC: ['./src/pageC/main.js', './src/pageC/main2.js'],
      },
      output: {
        path: path.resolve(__dirname, 'dist'),
        // 配置合并 js 文件的规则
        filename: "[name]-[chunkhash:5].js"
      }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12

适用于重复代码较少的情况,只会影响传输请求。那么为什么不能打包一个 common chunk 呢?

  • 原因在于打包后的模块是单独的作用域的。而且由于依赖关系,原来的模块打包的代码中也是包含 common 的代码的。

# 一个页面多个 js

  • 源码结构:

    src
      - pageA
        - index.js
      - pageB
        - index.js
      - statistics 用于统计访问人数功能
        - index.js
      - common 公共目录
        - ...
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    • webpack:
    module.exports = {
      entry: {
        pageA: './src/pageA/index.js',
        pageB: './src/pageB/index.js',
        statistics: './src/statistics/index.js'
      output: {
        path: path.resolve(__dirname, 'dist'),
        // 配置合并 js 文件的规则
        filename: "[name]-[chunkhash:5].js"
      }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11

模块间不依赖,完全独立,就可以单独开一个 chunk 。

# 单页应用

  • 源码结构:

    src
      - sunFunc  子功能
        - index.js
      - sunFunc  子功能
        - index.js
      - common  公共目录
        - ...
    
    1
    2
    3
    4
    5
    6
    7
    • webpack:
    module.exports = {
      entry: {
        main: './src/index.js',
      output: {
        path: path.resolve(__dirname, 'dist'),
        // 配置合并 js 文件的规则
        filename: "[name]-[hash:5].js"
      }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9

# loader

本质是一个函数,它的作用是将某个源码字符串换成另一个源码字符串返回。

读取文件内容,分析语法树之前,会处理 loaders 。在打包过程中执行,所以不直接支持 es6 module (需要做特殊的处理)。

  • 当前模块是否满足 loader 处理条件。

  • 否,生成空数组,直接做语法树分析。是,读取 loaders 数组, 依次处理。

  • 使用插件 loader-utils 来获取传递的参数。

  • 从上往下解析匹配,但是从下往上,从右往左运行! !!

    module: {
          rules: [
            // 从上往下匹配,但是从下往上运行
              {
                  test: /test-loader\.js$/, //正则表达式,匹配模块的路径
                  use: ["./loaders/test-loader.js?changeVal=未知数", "./loaders/loader1.js"]
                    // options: {
                    //     changeVal: "未知数"
                    // }
                   //匹配到了之后,使用哪些加载器
              }, //规则1
              {
                  test: /test-loader\.js$/, //正则表达式,匹配模块的路径
                  use: ["./loaders/loader1.js", "./loaders/loader2.js"] //匹配到了之后,使用哪些加载器
              } //规则2
          ], //模块的匹配规则
      }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17

# 样式、图片处理

// 自定义的 loaders
// 样式处理
module.exports = function(sourceCode){
  return `
    const styleLabel = document.createElement('style');
    styleLabel.innerHTML = \`${sourceCode}\`;
    document.head.appendChild(styleLabel);
    module.exports=\`${sourceCode}\`;
    `;
}

// 图片处理
var loaderUtil = require("loader-utils")

function loader(buffer) { //给的是buffer
    console.log("文件数据大小:(字节)", buffer.byteLength);
    var { limit = 1000, filename = "[contenthash].[ext]" } = loaderUtil.getOptions(this);
    if (buffer.byteLength >= limit) {
        var content = getFilePath.call(this, buffer, filename);
        content = "../dist/" + content;
    }
    else{
        var content = getBase64(buffer)
    }
    return `module.exports = \`${content}\``;
}

loader.raw = true; //该loader要处理的是原始数据

module.exports = loader;

function getBase64(buffer) {
    return "data:image/png;base64," + buffer.toString("base64");
}

function getFilePath(buffer, name) {
    var filename = loaderUtil.interpolateName(this, name, {
        content: buffer
    });
    this.emitFile(filename, buffer);
    return filename;
}

// index.js 中的内容
// 处理样式
const content = require('./assets/style.css');
console.log(content);

// 处理图片
var src = require("./assets/webpack.png")
console.log(src);
var img = document.createElement("img")
img.src = src;
document.body.appendChild(img);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54

# plugin

loader 做代码转化的,比较局限。复杂功能的实现需要插件。如:

  • 当 webpack 生成文件时,顺便多生产一个说明描述文件。
  • 当 webpack 编译的时候,控制台输出一句话表示 webpack 启动。
  • 当xxxx时,xxxx

这种类似的功能需要把功能嵌入到 webpack 的编译流程中,而这种事情的实现是依托于 plugin 的。

plugin的本质是一个带有apply方法的对象。

var plugin = {
    apply: function(compiler){

    }
}
1
2
3
4
5

通常,习惯上,我们会将该对象写成构造函数的模式

class MyPlugin{
    apply(compiler){

    }
}

var plugin = new MyPlugin();
1
2
3
4
5
6
7

要将插件应用到webpack,需要把插件对象配置到webpack的plugins数组中,如下:

module.exports = {
    plugins:[
        new MyPlugin()
    ]
}
1
2
3
4
5

apply函数会在初始化阶段,创建好Compiler对象后运行。

compiler对象是在初始化阶段构建的,整个webpack打包期间只有一个compiler对象,后续完成打包工作的是compiler对象内部创建的compilation

apply方法会在创建好compiler对象后调用,并向方法传入一个compiler对象

compiler对象提供了大量的钩子函数(hooks,可以理解为事件),plugin的开发者可以注册这些钩子函数,参与webpack编译和生成。

你可以在apply方法中使用下面的代码注册钩子函数:

class MyPlugin{
    apply(compiler){
        compiler.hooks.事件名称.事件类型(name, function(compilation){
            //事件处理函数
        })
    }
}
1
2
3
4
5
6
7

事件名称

即要监听的事件名,即钩子名,所有的钩子:https://www.webpackjs.com/api/compiler-hooks

事件类型

这一部分使用的是 Tapable API,这个小型的库是一个专门用于钩子函数监听的库。

它提供了一些事件类型:

  • tap:注册一个同步的钩子函数,函数运行完毕则表示事件处理结束
  • tapAsync:注册一个基于回调的异步的钩子函数,函数通过调用一个回调表示事件处理结束
  • tapPromise:注册一个基于 Promise 的异步的钩子函数,函数通过返回的 Promise 进入已决状态表示事件处理结束

处理函数

处理函数有一个事件参数compilation

# 配置细节 - 细枝末节

  • 配置文件如果导出一个函数,则会将返回结果作为配置对象。可以通过命令指定环境,通过一个配置文件配置多个环境。webpack --env='xxx'
  • context: 会影响入口和 loader 的路径配置基础。
  • output: { library, libraryTarget } 打包结果立即执行函数结果会暴露给 library 规定的 变量。
    • 和插件一起配合得到打包结果。
    • 写的不是一个工程,是一个 库,类似于 jquery ,希望别人使用 $ 等全局变量。
    • libraryTarget 更精细控制导出。
  • target: "web" 打包结果执行的环境,默认是 web 。还可以设置 node 。
  • module: { noParse: /jquery/ } 不用解析,例如针对 jquery 等大型的单模块库(已经打包过的,没有其他依赖),来提高构建效率,和运行时没有任何关系,呵呵了!
  • resolve: {  
      // modules 模块的查找位置
      modules: ["node_modules"],
      // require("./a") webapck 会从 extensions 中补全后缀
      extensions: [".js", ".json"],
      // 别名,这个比较高频使用!!
      alias: {
        "@": path.resolve(__dirname, 'src'),
        "_": __dirname,
      }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
  • // 通过 cdn 引入了,遇到哪个包不用管,替换为导出一个 $ _
    externals: {
      jquery: "$",
      lodash: "_",
    }
    
    1
    2
    3
    4
    5
  • stats webpack 执行后控制台的输出内容。
更新时间: 2022年7月9日星期六 18:39