webpack学习

1、webpack

1-1 webpack 是什么

webpack 是一种前端资源构建工具,一个静态模块打包器(module bundler)。

在webpack 看来, 前端的所有资源文件(js/json/css/img/less/...)都会作为模块处理(chunk)。
它将根据模块的依赖关系进行静态分析,打包生成对应的静态资源(bundle)。

1-2 webpack 五个核心概念

1.2.1 Entry

入口(Entry):指示 webpack 以哪个文件为入口起点开始打包,分析构建内部依赖图。

1.2.2 Output

输出(Output):指示 webpack 打包后的资源 bundles 输出到哪里去,以及如何命名。

1.2.3 Loader

Loader:让 webpack 能够去处理那些非 JS 的文件,比如样式文件、图片文件(webpack 自身只理解
JS)

1.2.4 Plugins

插件(Plugins):可以用于执行范围更广的任务。插件的范围包括,从打包优化和压缩,
一直到重新定义环境中的变量等。

1.2.5 Mode

模式(Mode):指示 webpack 使用相应模式的配置。

选项描述特点
development 会将 DefinePlugin 中 process.env.NODE_ENV 的值设置为 development。启用 NamedChunksPlugin 和 NamedModulesPlugin。 能让代码本地调试运行的环境
production 会将 DefinePlugin 中 process.env.NODE_ENV 的值设置为 production。启用 FlagDependencyUsagePlugin, FlagIncludedChunksPlugin, ModuleConcatenationPlugin, NoEmitOnErrorsPlugin, OccurrenceOrderPlugin, SideEffectsFlagPlugin 和 TerserPlugin。 能让代码优化上线运行的环境

2、Webpack 初体验

2-1 初始化配置

  1. 初始化 package.json:npm init

  2. 下载安装webpack:(webpack4以上的版本需要全局/本地都安装webpack-cli)

    全局安装:cnpm i webpack webpack-cli -g

    本地安装:cnpm i webpack webpack-cli -D

2-2 编译打包应用

创建 src 下的 js 等文件后,不需要配置 webpack.config.js 文件,在命令行就可以编译打包。

指令:

  • 开发环境:webpack ./src/index.js -o ./build/built.js --mode=development

    webpack会以 ./src/index.js 为入口文件开始打包,打包后输出到 ./build/built.js 整体打包环境,是开发环境

  • 生产环境:webpack ./src/index.js -o ./build/built.js --mode=production

    webpack会以 ./src/index.js 为入口文件开始打包,打包后输出到 ./build/built.js 整体打包环境,是生产环境

结论:

  1. webpack 本身能处理 js/json 资源,不能处理 css/img 等其他资源

  2. 生产环境和开发环境将 ES6 模块化编译成浏览器能识别的模块化,但是不能处理 ES6 的基本语法转化为 ES5(需要借助 loader)

  3. 生产环境比开发环境多一个压缩 js 代码

3、Webpack 开发环境的基本配置

webpack.config.js 是 webpack 的配置文件。

作用: 指示 webpack 干哪些活(当你运行 webpack 指令时,会加载里面的配置)

所有构建工具都是基于 nodejs 平台运行的,模块化默认采用 commonjs。

开发环境配置主要是为了能让代码运行。主要考虑以下几个方面:

  • 打包样式资源
  • 打包 html 资源
  • 打包图片资源
  • 打包其他资源
  • devServer

下面是一个简单的开发环境webpack.confg.js配置文件


/*
  开发环境配置:能让代码运行
    运行项目指令:
      webpack 会将打包结果输出出去
      npx webpack-dev-server 只会在内存中编译打包,没有输出
*/
1 // resolve用来拼接绝对路径的方法
  2 const { resolve } = require('path')
  3 const HtmlWebpackPlugin = require('html-webpack-plugin') // 引用plugin
  4 
  5 module.exports = {
  6   // webpack配置
  7   entry: './src/js/index.js', // 入口起点
  8   output: {
  9     // 输出
 10     // 输出文件名
 11     filename: 'js/build.js',
 12     // __dirname是nodejs的变量,代表当前文件的目录绝对路径
 13     path: resolve(__dirname, 'build'), // 输出路径,所有资源打包都会输出到这个文件夹下
 14   },
 15   // loader配置
 16   module: {
 17     rules: [
 18       // 详细的loader配置
 19       // 不同文件必须配置不同loader处理
 20       {
 21         // 匹配哪些文件
 22         test: /\.less$/,
 23         // 使用哪些loader进行处理
 24         use: [
 25           // use数组中loader执行顺序:从右到左,从下到上,依次执行(先执行css-loader)
 26           // style-loader:创建style标签,将js中的样式资源插入进去,添加到head中生效
 27           'style-loader',
 28           // css-loader:将css文件变成commonjs模块加载到js中,里面内容是样式字符串
 29           'css-loader',
 30           // less-loader:将less文件编译成css文件,需要下载less-loader和less
 31           'less-loader'
 32         ],
 33       },
 34       {
 35         test: /\.css$/,
 36         use: ['style-loader', 'css-loader'],
 37       },
 38       {
 39         // url-loader:处理图片资源,问题:默认处理不了html中的img图片
 40         test: /\.(jpg|png|gif)$/,
 41         // 需要下载 url-loader file-loader
 42         loader: 'url-loader',
 43         options: {
 44           // 图片大小小于8kb,就会被base64处理,优点:减少请求数量(减轻服务器压力),缺点:图片体积会更大(文件请求速度更慢)
 45           // base64在客户端本地解码所以会减少服务器压力,如果图片过大还采用base64编码会导致cpu调用率上升,网页加载时变卡
 46           limit: 8 * 1024,
 47           // 给图片重命名,[hash:10]:取图片的hash的前10位,[ext]:取文件原来扩展名
 48           name: '[hash:10].[ext]',
 49           // 问题:因为url-loader默认使用es6模块化解析,而html-loader引入图片是conmonjs,解析时会出问题:[object Module]
 50           // 解决:关闭url-loader的es6模块化,使用commonjs解析
 51           esModule: false,
 52           outputPath: 'imgs',
 53         },
 54       },
 55       {
 56         test: /\.html$/,
 57         // 处理html文件的img图片(负责引入img,从而能被url-loader进行处理)
 58         loader: 'html-loader',
 59         options:{
 60           esModule: false,
 61          }
 62       },
 63       // 打包其他资源(除了html/js/css资源以外的资源)
 64       {
 65         // 排除html|js|css|less|jpg|png|gif文件
 66         exclude: /\.(html|js|css|less|jpg|png|gif)/,
 67         // file-loader:处理其他文件
 68         loader: 'file-loader',
 69         options: {
 70           name: '[hash:10].[ext]',
 71           outputPath: 'media',
 72         },
 73       },
 74     ],
 75   },
 76   // plugin的配置
 77   plugins: [
 78     // html-webpack-plugin:默认会创建一个空的html文件,自动引入打包输出的所有资源(JS/CSS)
 79     // 需要有结构的HTML文件可以加一个template
 80     new HtmlWebpackPlugin({
 81       // 复制这个./src/index.html文件,并自动引入打包输出的所有资源(JS/CSS)
 82       template: './src/index.html',
 83     }),
 84   ],
 85   // 模式
 86   mode: 'development', // 开发模式
 87   // 开发服务器 devServer:用来自动化,不用每次修改后都重新输入webpack打包一遍(自动编译,自动打开浏览器,自动刷新浏览器)
 88   // 特点:只会在内存中编译打包,不会有任何输出(不会像之前那样在外面看到打包输出的build包,而是在内存中,关闭后会自动删除)
 89   // 启动devServer指令为:npx webpack-dev-server or  npx webpack serve(视webpack版本而定)
 90   devServer: {
 91     // 项目构建后路径
 92     contentBase: resolve(__dirname, 'build'),
 93     // 启动gzip压缩
 94     compress: true,
 95     // 端口号
 96     port: 3000,
 97     // 自动打开浏览器
 98     open: true,
 99   },
100 }         

4、Webpack 生产环境的基本配置

而生产环境的配置需要考虑以下几个方面:

  • 提取 css 成单独文件
  • css 兼容性处理
  • 压缩 css
  • js 语法检查
  • js 兼容性处理
  • js 压缩
  • html 压缩
  1 const { resolve } = require('path')
// 抽离css样式,防止css样式打包在js文件中,因为文件过大出现网络超时情况
2 const MiniCssExtractorPlugin = require('mini-css-extract-plugin')
// 压缩css文件
3 const OptimiziCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin') 4 const HtmlWebpackPlugin = require('html-webpack-plugin') 5 6 // 定义node.js的环境变量,决定使用browserslist的哪个环境 7 process.env.NODE_ENV = 'production' 8 9 // 复用loader的写法 10 const commonCssLoader = [ 11 // 这个loader取代style-loader。作用:提取js中的css成单独文件然后通过link加载 12 MiniCssExtractPlugin.loader, 13 // css-loader:将css文件整合到js文件中 14 // 经过css-loader处理后,样式文件是在js文件中的 15 // 问题:1.js文件体积会很大2.需要先加载js再动态创建style标签,样式渲染速度就慢,会出现闪屏现象 16 // 解决:用MiniCssExtractPlugin.loader替代style-loader 17 'css-loader', 18 /* 19 postcss-loader:css兼容性处理:postcss --> 需要安装:postcss-loader postcss-preset-env 20 postcss需要通过package.json中browserslist里面的配置加载指定的css兼容性样式 21 在package.json中定义browserslist: 22 "browserslist": { 23 // 开发环境 --> 设置node环境变量:process.env.NODE_ENV = development 24 "development": [ // 只需要可以运行即可 25 "last 1 chrome version", 26 "last 1 firefox version", 27 "last 1 safari version" 28 ], 29 // 生产环境。默认是生产环境 30 "production": [ // 需要满足绝大多数浏览器的兼容 31 ">0.2%", 32 "not dead", 33 "not op_mini all" 34 ] 35 }, 36 */ 37 { 38 loader: 'postcss-loader', 39 ident: 'postcss', // 基本写法 40 options: { 41 postcssOptions: { 42 plugins: () => [ 43 // postcss的插件 44 require('postcss-preset-env')(), 45 ], 46 } 47 }, 48 } 49 ] 50 51 module.exports = { 52 entry: './src/js/index.js', 53 output: { 54 filename: 'js/built.js', 55 path: resolve(__dirname, 'build'), 56 }, 57 module: { 58 rules: [ 59 { 60 test: /\.css$/, 61 use: [...commonCssLoader], 62 }, 63 { 64 test: /\.less$/, 65 use: [...commonCssLoader, 'less-loader'], 66 }, 67 /* 68 正常来讲,一个文件只能被一个loader处理 69 当一个文件要被多个loader处理,那么一定要指定loader执行的先后顺序 70 先执行eslint再执行babel(用enforce) 71 */ 72 { 73 /* 74 js的语法检查: 需要下载 eslint-loader eslint 75 注意:只检查自己写的源代码,第三方的库是不用检查的 76 airbnb(一个流行的js风格) --> 需要下载 eslint-config-airbnb-base eslint-plugin-import 77 设置检查规则: 78 package.json中eslintConfig中设置 79 "eslintConfig": { 80 "extends": "airbnb-base", // 继承airbnb的风格规范 81 "env": { 82 "browser": true // 可以使用浏览器中的全局变量(使用window不会报错) 83 } 84 } 85 */ 86 test: /\.js$/, 87 exclude: /node_modules/, // 忽略node_modules 88 enforce: 'pre', // 优先执行 89 loader: 'eslint-loader', 90 options: { 91 // 自动修复 92 fix: true, 93 }, 94 }, 95 /* 96 js兼容性处理:需要下载 babel-loader @babel/core 97 1. 基本js兼容性处理 --> @babel/preset-env 98 问题:只能转换基本语法,如promise高级语法不能转换 99 2. 全部js兼容性处理 --> @babel/polyfill 100 问题:只要解决部分兼容性问题,但是将所有兼容性代码全部引入,体积太大了 101 3. 需要做兼容性处理的就做:按需加载 --> core-js 102 */ 103 { 104 // 第三种方式:按需加载 105 test: /\.js$/, 106 exclude: /node_modules/, 107 loader: 'babel-loader', 108 options: { 109 // 预设:指示babel做怎样的兼容性处理 110 presets: [ 111 '@babel/preset-env', // 基本预设 112 { 113 useBuiltIns: 'usage', //按需加载 114 corejs: { version: 3 }, // 指定core-js版本 115 targets: { // 指定兼容到什么版本的浏览器 116 chrome: '60', 117 firefox: '50', 118 ie: '9', 119 safari: '10', 120 edge: '17' 121 }, 122 }, 123 ], 124 }, 125 }, 126 { 127 // 图片处理 128 test: /\.(jpg|png|gif)/, 129 loader: 'url-loader', 130 options: { 131 limit: 8 * 1024, 132 name: '[hash:10].[ext]', 133 outputPath: 'imgs', 134 esModule: false, // 关闭url-loader默认使用的es6模块化解析 135 }, 136 }, 137 // html中的图片处理 138 { 139 test: /\.html$/, 140 loader: 'html-loader', 141 }, 142 // 处理其他文件 143 { 144 exclude: /\.(js|css|less|html|jpg|png|gif)/, 145 loader: 'file-loader', 146 options: { 147 outputPath: 'media', 148 }, 149 }, 150 ], 151 }, 152 plugins: [ 153 new MiniCssExtractPlugin({ 154 // 对输出的css文件进行重命名 155 filename: 'css/built.css', 156 }), 157 // 压缩css 158 new OptimizeCssAssetsWebpackPlugin(), 159 // HtmlWebpackPlugin:html文件的打包和压缩处理 160 // 通过这个插件会自动将单独打包的样式文件通过link标签引入 161 new HtmlWebpackPlugin({ 162 template: './src/index.html', 163 // 压缩html代码 164 minify: { 165 // 移除空格 166 collapseWhitespace: true, 167 // 移除注释 168 removeComments: true, 169 }, 170 }), 171 ], 172 // 生产环境下会自动压缩js代码 173 mode: 'production', 174 }

5、Webpack 优化配置

5-1 开发环境性能优化

5.1.1 HMR(模块热替换)

HMR: hot module replacement 热模块替换 / 模块热替换

作用:一个模块发生变化,只会重新打包构建这一个模块(而不是打包所有模块),极大提升构建速度

代码:只需要在 devServer 中设置 hot 为 true,就会自动开启HMR功能(只能在开发模式下使用)

devServer: {
  contentBase: resolve(__dirname, 'build'),
  compress: true,
  port: 3000,
  open: true,
  // 开启HMR功能
  // 当修改了webpack配置,新配置要想生效,必须重启webpack服务
  hot: true
}

每种文件实现热模块替换的情况:

  • 样式文件:可以使用HMR功能,因为开发环境下使用的 style-loader 内部默认实现了热模块替换功能

  • js 文件:默认不能使用HMR功能(修改一个 js 模块所有 js 模块都会刷新)

    --> 实现 HMR 需要修改 js 代码(添加支持 HMR 功能的代码)

    // 绑定
    if (module.hot) {
      // 一旦 module.hot 为true,说明开启了HMR功能。 --> 让HMR功能代码生效
      module.hot.accept('./print.js', function() {
        // 方法会监听 print.js 文件的变化,一旦发生变化,只有这个模块会重新打包构建,其他模块不会。
        // 会执行后面的回调函数
        print();
      });
    }

    注意:HMR 功能对 js 的处理,只能处理非入口 js 文件的其他文件。

  • html 文件: 默认不能使用 HMR 功能(html 不用做 HMR 功能,因为只有一个 html 文件,不需要再优化)

    使用 HMR 会导致问题:html 文件不能热更新了(不会自动打包构建)

    解决:修改 entry 入口,将 html 文件引入(这样 html 修改整体刷新)

    entry: ['./src/js/index.js', './src/index.html']

5.1.2 source-map

source-map:一种提供源代码到构建后代码的映射的技术 (如果构建后代码出错了,通过映射可以追踪源代码错误)

参数:[inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map

代码:

devtool: 'eval-source-map'

可选方案:[生成source-map的位置|给出的错误代码信息]

  • source-map:外部,错误代码准确信息 和 源代码的错误位置

  • inline-source-map:内联,只生成一个内联 source-map,错误代码准确信息 和 源代码的错误位置

  • hidden-source-map:外部,错误代码错误原因,但是没有错误位置(为了隐藏源代码),不能追踪源代码错误,只能提示到构建后代码的错误位置

  • eval-source-map:内联,每一个文件都生成对应的 source-map,都在 eval 中,错误代码准确信息 和 源代码的错误位

  • nosources-source-map:外部,错误代码准确信息,但是没有任何源代码信息(为了隐藏源代码)

  • cheap-source-map:外部,错误代码准确信息 和 源代码的错误位置,只能把错误精确到整行,忽略列

  • cheap-module-source-map:外部,错误代码准确信息 和 源代码的错误位置,module 会加入 loader 的 source-map

内联 和 外部的区别:1. 外部生成了文件,内联没有 2. 内联构建速度更快

开发/生产环境可做的选择:

开发环境:需要考虑速度快,调试更友好

  • 速度快( eval > inline > cheap >... )

    1. eval-cheap-souce-map

    2. eval-source-map

  • 调试更友好

    1. souce-map

    2. cheap-module-souce-map

    3. cheap-souce-map

最终得出最好的两种方案 --> eval-source-map(完整度高,内联速度快) / eval-cheap-module-souce-map(错误提示忽略列但是包含其他信息,内联速度快)

生产环境:需要考虑源代码要不要隐藏,调试要不要更友好

  • 内联会让代码体积变大,所以在生产环境不用内联

  • 隐藏源代码

    1. nosources-source-map 全部隐藏
    2. hidden-source-map 只隐藏源代码,会提示构建后代码错误信息

最终得出最好的两种方案 --> source-map(最完整) / cheap-module-souce-map(错误提示一整行忽略列)

5-2 生产环境性能优化

5.2.1 优化打包构建速度

5.2.1.1 oneOf

oneOf:匹配到 loader 后就不再向后进行匹配,优化生产环境的打包构建速度

module: {
  rules: [
    {
      // js 语法检查
      test: /\.js$/,
      exclude: /node_modules/,
      // 优先执行
      enforce: 'pre',
      loader: 'eslint-loader',
      options: {
        fix: true
      }
    },
    {
      // oneOf 优化生产环境的打包构建速度
      // 以下loader只会匹配一个(匹配到了后就不会再往下匹配了)
      // 注意:不能有两个配置处理同一种类型文件(所以把eslint-loader提取出去放外面)
      oneOf: [
        {
          test: /\.css$/,
          use: [...commonCssLoader]
        },
        {
          test: /\.less$/,
          use: [...commonCssLoader, 'less-loader']
        },
        {
          // js 兼容性处理
          test: /\.js$/,
          exclude: /node_modules/,
          loader: 'babel-loader',
          options: {
            presets: [
              [
                '@babel/preset-env',
                {
                  useBuiltIns: 'usage',
                  corejs: {version: 3},
                  targets: {
                    chrome: '60',
                    firefox: '50'
                  }
                }
              ]
            ]
          }
        },
        {
          test: /\.(jpg|png|gif)/,
          loader: 'url-loader',
          options: {
            limit: 8 * 1024,
            name: '[hash:10].[ext]',
            outputPath: 'imgs',
            esModule: false
          }
        },
        {
          test: /\.html$/,
          loader: 'html-loader'
        },
        {
          exclude: /\.(js|css|less|html|jpg|png|gif)/,
          loader: 'file-loader',
          options: {
            outputPath: 'media'
          }
        }
      ]
    }
  ]
},

5.2.1.2 babel 缓存

babel 缓存:类似 HMR,将 babel 处理后的资源缓存起来(哪里的 js 改变就更新哪里,其他 js 还是用之前缓存的资源),让第二次打包构建速度更快

{
  test: /\.js$/,
  exclude: /node_modules/,
  loader: 'babel-loader',
  options: {
    presets: [
      [
        '@babel/preset-env',
        {
          useBuiltIns: 'usage',
          corejs: { version: 3 },
          targets: {
            chrome: '60',
            firefox: '50'
          }
        }
      ]
    ],
    // 开启babel缓存
    // 第二次构建时,会读取之前的缓存
    cacheDirectory: true
  }
},

文件资源缓存

文件名不变,就不会重新请求,而是再次用之前缓存的资源

1.hash: 每次 wepack 打包时会生成一个唯一的 hash 值。

​ 问题:重新打包,所有文件的 hsah 值都改变,会导致所有缓存失效。(可能只改动了一个文件)

2.chunkhash:根据 chunk 生成的 hash 值。来源于同一个 chunk的 hash 值一样

​ 问题:js 和 css 来自同一个chunk,hash 值是一样的(因为 css-loader 会将 css 文件加载到 js 中,所以同属于一个chunk)

3.contenthash: 根据文件的内容生成 hash 值。不同文件 hash 值一定不一样(文件内容修改,文件名里的 hash 才会改变)

修改 css 文件内容,打包后的 css 文件名 hash 值就改变,而 js 文件没有改变 hash 值就不变,这样 css 和 js 缓存就会分开判断要不要重新请求资源 --> 让代码上线运行缓存更好使用

5.2.1.3 多进程打包

多进程打包:某个任务消耗时间较长会卡顿,多进程可以同一时间干多件事,效率更高。

优点是提升打包速度,缺点是每个进程的开启和交流都会有开销(babel-loader消耗时间最久,所以使用thread-loader针对其进行优化)

{
  test: /\.js$/,
  exclude: /node_modules/,
  use: [
    /* 
      thread-loader会对其后面的loader(这里是babel-loader)开启多进程打包。 
      进程启动大概为600ms,进程通信也有开销。(启动的开销比较昂贵,不要滥用)
      只有工作消耗时间比较长,才需要多进程打包
    */
    {
      loader: 'thread-loader',
      options: {
        workers: 2 // 进程2个
      }
    },
    {
      loader: 'babel-loader',
      options: {
        presets: [
          [
            '@babel/preset-env',
            {
              useBuiltIns: 'usage',
              corejs: { version: 3 },
              targets: {
                chrome: '60',
                firefox: '50'
              }
            }
          ]
        ],
        // 开启babel缓存
        // 第二次构建时,会读取之前的缓存
        cacheDirectory: true
      }
    }
  ]
},

5.2.1.4 externals

externals:让某些库不打包,通过 cdn 引入

webpack.config.js 中配置:

externals: {
  // 拒绝jQuery被打包进来(通过cdn引入,速度会快一些)
  // 忽略的库名 -- npm包名
  jquery: 'jQuery'
}

需要在 index.html 中通过 cdn 引入:

<script src="https://cdn.bootcss.com/jquery/1.12.4/jquery.min.js"></script>

5.2.1.5 dll

dll:某些库只打包一次,后续不再打包。让某些库单独打包,后直接引入到 build 中。可以在 code split 分割出 node_modules 后再用 dll 更细的分割,优化代码运行的性能。

webpack.dll.js 配置:(将 jquery 单独打包)

/*
  node_modules的库会打包到一起,但是很多库的时候打包输出的js文件就太大了
  使用dll技术,对某些库(第三方库:jquery、react、vue...)进行单独打包
  当运行webpack时,默认查找webpack.config.js配置文件
  需求:需要运行webpack.dll.js文件
    --> webpack --config webpack.dll.js(运行这个指令表示以这个配置文件打包)
*/
const { resolve } = require('path');
const webpack = require('webpack');

module.exports = {
  entry: {
    // 最终打包生成的[name] --> jquery
    // ['jquery] --> 要打包的库是jquery
    jquery: ['jquery']
  },
  output: {
    // 输出出口指定
    filename: '[name].js', // name就是jquery
    path: resolve(__dirname, 'dll'), // 打包到dll目录下
    library: '[name]_[hash]', // 打包的库里面向外暴露出去的内容叫什么名字
  },
  plugins: [
    // 打包生成一个manifest.json --> 提供jquery的映射关系(告诉webpack:jquery之后不需要再打包和暴露内容的名称)
    new webpack.DllPlugin({
      name: '[name]_[hash]', // 映射库的暴露的内容名称
      path: resolve(__dirname, 'dll/manifest.json') // 输出文件路径
    })
  ],
  mode: 'production'
};

webpack.config.js 配置:(告诉 webpack 不需要再打包 jquery,并将之前打包好的 jquery 跟其他打包好的资源一同输出到 build 目录下)

// 引入插件
const webpack = require('webpack');
const AddAssetHtmlWebpackPlugin = require('add-asset-html-webpack-plugin');

// plugins中配置:
plugins: [
  new HtmlWebpackPlugin({
    template: './src/index.html'
  }),
  // 告诉webpack哪些库不参与打包,同时使用时的名称也得变
  new webpack.DllReferencePlugin({
    manifest: resolve(__dirname, 'dll/manifest.json')
  }),
  // 将某个文件打包输出到build目录下,并在html中自动引入该资源
  new AddAssetHtmlWebpackPlugin({
    filepath: resolve(__dirname, 'dll/jquery.js')
  })
],

5.2.2 优化代码运行的性能

5.2.2.1 缓存

5.2.2.2 tree shaking(树摇)

tree shaking:去除无用代码

前提:1. 必须使用 ES6 模块化 2. 开启 production 环境 (这样就自动会把无用代码去掉)

作用:减少代码体积

在 package.json 中配置:

"sideEffects": false 表示所有代码都没有副作用(都可以进行 tree shaking)

这样会导致的问题:可能会把 css / @babel/polyfill 文件干掉(副作用)

所以可以配置:"sideEffects": ["*.css", "*.less"] 不会对css/less文件tree shaking处理

5.2.2.3 code split(代码分割)

代码分割。将打包输出的一个大的 bundle.js 文件拆分成多个小文件,这样可以并行加载多个文件,比加载一个文件更快。

1.多入口拆分

entry: {
    // 多入口:有一个入口,最终输出就有一个bundle
    index: './src/js/index.js',
    test: './src/js/test.js'
  },
  output: {
    // [name]:取文件名
    filename: 'js/[name].[contenthash:10].js',
    path: resolve(__dirname, 'build')
  },

2.optimization:

optimization: {
    splitChunks: {
      chunks: 'all'
    }
  },
  • 将 node_modules 中的代码单独打包(大小超过30kb)
  • 自动分析多入口chunk中,有没有公共的文件。如果有会打包成单独一个chunk(比如两个模块中都引入了jquery会被打包成单独的文件)(大小超过30kb)

3.import 动态导入语法:

/*
  通过js代码,让某个文件被单独打包成一个chunk
  import动态导入语法:能将某个文件单独打包(test文件不会和index打包在同一个文件而是单独打包)
  webpackChunkName:指定test单独打包后文件的名字
*/
import(/* webpackChunkName: 'test' */'./test')
  .then(({ mul, count }) => {
    // 文件加载成功~
    // eslint-disable-next-line
    console.log(mul(2, 5));
  })
  .catch(() => {
    // eslint-disable-next-line
    console.log('文件加载失败~');
  });

5.2.2.4 lazy loading(懒加载/预加载)

1.懒加载:当文件需要使用时才加载(需要代码分割)。但是如果资源较大,加载时间就会较长,有延迟。

2.正常加载:可以认为是并行加载(同一时间加载多个文件)没有先后顺序,先加载了不需要的资源就会浪费时间。

3.预加载 prefetch(兼容性很差):会在使用之前,提前加载。等其他资源加载完毕,浏览器空闲了,再偷偷加载这个资源。这样在使用时已经加载好了,速度很快。所以在懒加载的基础上加上预加载会更好。

代码:

document.getElementById('btn').onclick = function() {
  // 将import的内容放在异步回调函数中使用,点击按钮,test.js才会被加载(不会重复加载)
  // webpackPrefetch: true表示开启预加载
  import(/* webpackChunkName: 'test', webpackPrefetch: true */'./test').then(({ mul }) => {
    console.log(mul(4, 5));
  });
  import('./test').then(({ mul }) => {
    console.log(mul(2, 5))
  })
};

5.2.2.5 pwa(离线可访问技术)

pwa:离线可访问技术(渐进式网络开发应用程序),使用 serviceworker 和 workbox 技术。优点是离线也能访问,缺点是兼容性差。

webpack.config.js 中配置:

const WorkboxWebpackPlugin = require('workbox-webpack-plugin'); // 引入插件

// plugins中加入:
new WorkboxWebpackPlugin.GenerateSW({
  /*
    1. 帮助serviceworker快速启动
    2. 删除旧的 serviceworker

    生成一个 serviceworker 配置文件
  */
  clientsClaim: true,
  skipWaiting: true
})

index.js 中还需要写一段代码来激活它的使用:

/*
  1. eslint不认识 window、navigator全局变量
    解决:需要修改package.json中eslintConfig配置
    "env": {
      "browser": true // 支持浏览器端全局变量
    }
  2. sw代码必须运行在服务器上
    --> nodejs
    或-->
      npm i serve -g
      serve -s build 启动服务器,将打包输出的build目录下所有资源作为静态资源暴露出去
*/
if ('serviceWorker' in navigator) { // 处理兼容性问题
  window.addEventListener('load', () => {
    navigator.serviceWorker
      .register('/service-worker.js') // 注册serviceWorker
      .then(() => {
        console.log('sw注册成功了~');
      })
      .catch(() => {
        console.log('sw注册失败了~');
      });
  });
}

 总结:

 开发环境性能优化:
  * 优化打包构建速度
  * HMR
  * 优化代码调试
  * source-map  

生产环境性能优化:
  * 优化打包构建速度
  * oneOf
  * babel缓存
  * 多进程打包
  * externals
  * dll
  * 优化代码运行的性能
  * 缓存(hash-chunkhash-contenthash)
  * tree shaking
  * code split
  * 懒加载/预加载
  * pwa

6、Webpack 配置详情

6-1 entry

entry: 入口起点

  1. string --> './src/index.js',单入口

    打包形成一个 chunk。 输出一个 bundle 文件。此时 chunk 的名称默认是 main

  2. array --> ['./src/index.js', './src/add.js'],多入口

    所有入口文件最终只会形成一个 chunk,输出出去只有一个 bundle 文件。

    (一般只用在 HMR 功能中让 html 热更新生效)

  3. object,多入口

    有几个入口文件就形成几个 chunk,输出几个 bundle 文件,此时 chunk 的名称是 key 值

--> 特殊用法:

entry: {
  // 最终只会形成一个chunk, 输出出去只有一个bundle文件。
  index: ['./src/index.js', './src/count.js'], 
  // 形成一个chunk,输出一个bundle文件。
  add: './src/add.js'
}

6-2 output

output: {
  // 文件名称(指定名称+目录)
  filename: 'js/[name].js',
  // 输出文件目录(将来所有资源输出的公共目录)
  path: resolve(__dirname, 'build'),
  // 所有资源引入公共路径前缀 --> 'imgs/a.jpg' --> '/imgs/a.jpg'
  publicPath: '/',
  chunkFilename: 'js/[name]_chunk.js', // 指定非入口chunk的名称
  library: '[name]', // 打包整个库后向外暴露的变量名
  libraryTarget: 'window' // 变量名添加到哪个上 browser:window
  // libraryTarget: 'global' // node:global
  // libraryTarget: 'commonjs' // conmmonjs模块 exports
},

6-3 module

module: {
  rules: [
    // loader的配置
    {
      test: /\.css$/,
      // 多个loader用use
      use: ['style-loader', 'css-loader']
    },
    {
      test: /\.js$/,
      // 排除node_modules下的js文件
      exclude: /node_modules/,
      // 只检查src下的js文件
      include: resolve(__dirname, 'src'),
      enforce: 'pre', // 优先执行
      // enforce: 'post', // 延后执行
      // 单个loader用loader
      loader: 'eslint-loader',
      options: {} // 指定配置选项
    },
    {
      // 以下配置只会生效一个
      oneOf: []
    }
  ]
},

6-4 resolve

// 解析模块的规则
resolve: {
  // 配置解析模块路径别名: 优点:当目录层级很复杂时,简写路径;缺点:路径不会提示
  alias: {
    $css: resolve(__dirname, 'src/css')
  },
  // 配置省略文件路径的后缀名(引入时就可以不写文件后缀名了)
  extensions: ['.js', '.json', '.jsx', '.css'],
  // 告诉 webpack 解析模块应该去找哪个目录(不用一层一层网上找)
  modules: [resolve(__dirname, '../../node_modules'), 'node_modules']
}

这样配置后,引入文件就可以这样简写:import './src/index.css' ---> import '$css/index';

6-5 dev server

devServer: {
  // 运行代码所在的目录
  contentBase: resolve(__dirname, 'build'),
  // 监视contentBase目录下的所有文件,一旦文件变化就会reload
  watchContentBase: true,
  watchOptions: {
    // 忽略文件
    ignored: /node_modules/
  },
  // 启动gzip压缩
  compress: true,
  // 端口号
  port: 5000,
  // 域名
  host: 'localhost',
  // 自动打开浏览器
  open: true,
  // 开启HMR功能
  hot: true,
  // 不要显示启动服务器日志信息
  clientLogLevel: 'none',
  // 除了一些基本信息外,其他内容都不要显示
  quiet: true,
  // 如果出错了,不要全屏提示
  overlay: false,
  // 服务器代理,--> 解决开发环境跨域问题
  proxy: {
    // 一旦devServer(5000)服务器接收到/api/xxx的请求,就会把请求转发到另外一个服务器3000
    '/api': {
      target: 'http://localhost:3000',
      // 发送请求时,请求路径重写:将/api/xxx --> /xxx (去掉/api)
      pathRewrite: {
        '^/api': ''
      }
    }
  }
}

其中,跨域问题:同源策略中不同的协议、端口号、域名就会产生跨域。

正常的浏览器和服务器之间有跨域,但是服务器之间没有跨域。代码通过代理服务器运行,所以浏览器和代理服务器之间没有跨域,浏览器把请求发送到代理服务器上,代理服务器替你转发到另外一个服务器上,服务器之间没有跨域,所以请求成功。代理服务器再把接收到的响应响应给浏览器。这样就解决开发环境下的跨域问题。

6-6 optimization

contenthash 缓存会导致一个问题:修改 a 文件导致 b 文件 contenthash 变化。
因为在 index.js 中引入 a.js,打包后 index.js 中记录了 a.js 的 hash 值,而 a.js 改变,其重新打包后的 hash 改变,导致 index.js 文件内容中记录的 a.js 的 hash 也改变,从而重新打包后 index.js 的 hash 值也会变,这样就会使缓存失效。(改变的是a.js文件但是 index.js 文件的 hash 值也改变了)
解决办法:runtimeChunk --> 将当前模块记录其他模块的 hash 单独打包为一个文件 runtime,这样 a.js 的 hash 改变只会影响 runtime 文件,不会影响到 index.js 文件

output: {
  filename: 'js/[name].[contenthash:10].js',
  path: resolve(__dirname, 'build'),
  chunkFilename: 'js/[name].[contenthash:10]_chunk.js' // 指定非入口文件的其他chunk的名字加_chunk
},
optimization: {
  splitChunks: {
    chunks: 'all',
    /* 以下都是splitChunks默认配置,可以不写
    miniSize: 30 * 1024, // 分割的chunk最小为30kb(大于30kb的才分割)
    maxSize: 0, // 最大没有限制
    minChunks: 1, // 要提取的chunk最少被引用1次
    maxAsyncRequests: 5, // 按需加载时并行加载的文件的最大数量为5
    maxInitialRequests: 3, // 入口js文件最大并行请求数量
    automaticNameDelimiter: '~', // 名称连接符
    name: true, // 可以使用命名规则
    cacheGroups: { // 分割chunk的组
      vendors: {
        // node_modules中的文件会被打包到vendors组的chunk中,--> vendors~xxx.js
        // 满足上面的公共规则,大小超过30kb、至少被引用一次
        test: /[\\/]node_modules[\\/]/,
        // 优先级
        priority: -10
      },
      default: {
        // 要提取的chunk最少被引用2次
        minChunks: 2,
        prority: -20,
        // 如果当前要打包的模块和之前已经被提取的模块是同一个,就会复用,而不是重新打包
        reuseExistingChunk: true
      }
    } */
  },
  // 将index.js记录的a.js的hash值单独打包到runtime文件中
  runtimeChunk: {
    name: entrypoint => `runtime-${entrypoint.name}`
  },
  minimizer: [
    // 配置生产环境的压缩方案:js/css
    new TerserWebpackPlugin({
      // 开启缓存
      cache: true,
      // 开启多进程打包
      parallel: true,
      // 启用sourceMap(否则会被压缩掉)
      sourceMap: true
    })
  ]
}

7、Webpack5 介绍和使用

此版本重点关注以下内容:

  • 通过持久缓存提高构建性能.

  • 使用更好的算法和默认值来改善长期缓存.

  • 通过更好的树摇和代码生成来改善捆绑包大小.

  • 清除处于怪异状态的内部结构,同时在 v4 中实现功能而不引入任何重大更改.

  • 通过引入重大更改来为将来的功能做准备,以使我们能够尽可能长时间地使用 v5.

7-1 自动删除 Node.js Polyfills

早期,webpack 的目标是允许在浏览器中运行大多数 node.js 模块,但是模块格局发生了变化,许多模块用途现在主要是为前端目的而编写的。webpack <= 4 附带了许多 node.js 核心模块的 polyfill,一旦模块使用任何核心模块(即 crypto 模块),这些模块就会自动应用。

尽管这使使用为 node.js 编写的模块变得容易,但它会将这些巨大的 polyfill 添加到包中。在许多情况下,这些 polyfill 是不必要的。

webpack 5 会自动停止填充这些核心模块,并专注于与前端兼容的模块。

迁移:

  • 尽可能尝试使用与前端兼容的模块。

  • 可以为 node.js 核心模块手动添加一个 polyfill。错误消息将提示如何实现该目标。

Chunk 和模块 ID

添加了用于长期缓存的新算法。在生产模式下默认情况下启用这些功能。

你可以不用使用 import(/* webpackChunkName: "name" */ "module") 在开发环境来为 chunk 命名,生产环境还是有必要的

webpack 内部有 chunk 命名规则,不再是以 id(0, 1, 2)命名了

7-2 Tree Shaking

  1. webpack 现在能够处理对嵌套模块的 tree shaking
// inner.js
export const a = 1;
export const b = 2;

// module.js
import * as inner from './inner';
export { inner };

// user.js
import * as module from './module';
console.log(module.inner.a);

在生产环境中, inner 模块暴露的 b 会被删除

  1. webpack 现在能够多个模块之间的关系
import { something } from './something';

function usingSomething() {
  return something;
}

export function test() {
  return usingSomething();
}

当设置了"sideEffects": false时,一旦发现test方法没有使用,不但删除test,还会删除"./something"

  1. webpack 现在能处理对 Commonjs 的 tree shaking

7-3 Output

webpack 4 默认只能输出 ES5 代码

webpack 5 开始新增一个属性 output.ecmaVersion, 可以生成 ES5 和 ES6 / ES2015 代码.

如:output.ecmaVersion: 2015

SplitChunk

// webpack4
minSize: 30000;
// webpack5
minSize: {
  javascript: 30000,
  style: 50000,
}

7-4 Caching

// 配置缓存
cache: {
  // 磁盘存储
  type: "filesystem",
  buildDependencies: {
    // 当配置修改时,缓存失效
    config: [__filename]
  }
}

7-5 监视输出文件

之前 webpack 总是在第一次构建时输出全部文件,但是监视重新构建时会只更新修改的文件。

此次更新在第一次构建时会找到输出文件看是否有变化,从而决定要不要输出全部文件。

7-6 默认值

  • entry: "./src/index.js

  • output.path: path.resolve(__dirname, "dist")

  • output.filename: "[name].js"

 7-7 性能优化:

1、构建时间的优化:

  • thread-loader:

    多进程打包,可以大大提高构建的速度,使用方法是将thread-loader放在比较费时间的loader之前,比如babel-loader。由于启动项目和打包项目都需要加速,所以配置在webpack.base.js

npm i thread-loader -D
// webpack.base.js

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

   缓存资源,提高二次构建的速度,使用方法是将cache-loader放在比较费时间的loader之前,比如babel-loader

npm i cache-loader -D
// webpack.base.js

{
        test: /\.js$/,
        use: [
          'cache-loader',
          'thread-loader',
          'babel-loader'
        ],
},
  • 开启热更新

    比如你修改了项目中某一个文件,会导致整个项目刷新,这非常耗时间。如果只刷新修改的这个模块,其他保持原状,那将大大提高修改代码的重新构建时间。只用于开发中,所以配置在webpack.dev.js

// webpack.dev.js

//引入webpack
const webpack = require('webpack');
//使用webpack提供的热更新插件
   plugins: [
   new webpack.HotModuleReplacementPlugin()
    ],
    //最后需要在我们的devserver中配置
     devServer: {
+     hot: true
    },
  • exclude & include

  • exclude:不需要处理的文件
  • include:需要处理的文件

          合理设置这两个属性,可以大大提高构建速度

// webpack.base.js

      {
        test: /\.js$/,
        //使用include来指定编译文件夹
        include: path.resolve(__dirname, '../src'),
        //使用exclude排除指定文件夹
        exclude: /node_modules/,
        use: [
          'babel-loader'
        ]
      },
  • 构建区分环境

区分环境去构建是非常重要的,我们要明确知道,开发环境时我们需要哪些配置,不需要哪些配置;而最终打包生产环境时又需要哪些配置,不需要哪些配置:

  • 开发环境:去除代码压缩、gzip、体积分析等优化的配置,大大提高构建速度
  • 生产环境:需要代码压缩、gzip、体积分析等优化的配置,大大降低最终项目打包体积
  • 提升webpack版本

    webpack版本越新,打包的效果肯定更好

2、打包体积优化

   主要是打包后项目整体体积的优化,有利于项目上线后的页面加载速度提升

  • CSS代码压缩

  CSS代码压缩使用css-minimizer-webpack-plugin,效果包括压缩、去重

  代码的压缩比较耗时间,所以只用在打包项目时,所以只需要在webpack.prod.js中配置

npm i css-minimizer-webpack-plugin -D
// webpack.prod.js

const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')

  optimization: {
    minimizer: [
      new CssMinimizerPlugin(), // 去重压缩css
    ],
  }
  • JS代码压缩

  JS代码压缩使用terser-webpack-plugin,实现打包后JS代码的压缩

npm i terser-webpack-plugin -D
// webpack.prod.js

const TerserPlugin = require('terser-webpack-plugin')

  optimization: {
    minimizer: [
      new CssMinimizerPlugin(), // 去重压缩css
      new TerserPlugin({ // 压缩JS代码
        terserOptions: {
          compress: {
            drop_console: true, // 去除console
          },
        },
      }), // 压缩JavaScript
    ],
  }
  • tree-shaking

  tree-shaking简单说作用就是:只打包用到的代码,没用到的代码不打包,而webpack5默认开启tree-shaking,当打包的modeproduction时,自动开启tree-shaking进行优化

module.exports = {
  mode: 'production'
}
  • source-map类型

  source-map的作用是:方便你报错的时候能定位到错误代码的位置。它的体积不容小觑,所以对于不同环境设置不同的类型是很有必要的。

    开发环境:开发环境的时候我们需要能精准定位错误代码的位置

// webpack.dev.js

module.exports = {
  mode: 'development',
  devtool: 'eval-cheap-module-source-map'
}

    生产环境:生产环境,我们想开启source-map,但是又不想体积太大,那么可以换一种类型

// webpack.prod.js

module.exports = {
  mode: 'production',
  devtool: 'nosources-source-map'
}

7-3、用户体验优化

  • 模块懒加载

    如果不进行模块懒加载的话,最后整个项目代码都会被打包到一个js文件里,单个js文件体积非常大,那么当用户网页请求的时候,首屏加载时间会比较长,使用模块懒加载之后,大js文件会分成多个小js文件,网页加载时会按需加载,大大提升首屏加载速度

// src/router/index.js

const routes = [
  {
    path: '/login',
    name: 'login',
    component: login
  },
  {
    path: '/home',
    name: 'home',
    // 懒加载
    component: () => import('../views/home/home.vue'),
  },
]
  • Gzip

  开启Gzip后,大大提高用户的页面加载速度,因为gzip的体积比原文件小很多,当然需要后端的配合,使用compression-webpack-plugin

npm i compression-webpack-plugin -D
// webpack.prod.js

const CompressionPlugin = require('compression-webpack-plugin')

  plugins: [
    // 之前的代码...
    
    // gzip
    new CompressionPlugin({
      algorithm: 'gzip',
      threshold: 10240,
      minRatio: 0.8
    })
  ]
  • 小图片转base64

  对于一些小图片,可以转base64,这样可以减少用户的http网络请求次数,提高用户的体验。webpack5url-loader已被废弃,改用asset-module

// webpack.base.js

{
   test: /\.(png|jpe?g|gif|svg|webp)$/,
   type: 'asset',
   parser: {
     // 转base64的条件
     dataUrlCondition: {
        maxSize: 25 * 1024, // 25kb
     }
   },
   generator: {
     // 打包到 image 文件下
    filename: 'images/[contenthash][ext][query]',
   },
},
  • 合理配置hash

  我们要保证,改过的文件需要更新hash值,而没改过的文件依然保持原本的hash值,这样才能保证在上线后,浏览器访问时没有改变的文件会命中缓存,从而达到性能优化的目的

// webpack.base.js

  output: {
    path: path.resolve(__dirname, '../dist'),
    // 给js文件加上 contenthash
    filename: 'js/chunk-[contenthash].js',
    clean: true,
  },

 8、webpack编译原理

8-1 基本使用

const path = require("path");
module.exports = {
  mode: "development", //防止代码被压缩
  entry: "./src/index.js", //入口文件
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "[name].js",
  },
  devtool: "source-map", //防止干扰源文件
};

webpack本质是一个函数,接受一个配置信息作为参数,执行后返回一个 compiler 对象,调用compiler对象的 run 方法就会自动编译。

const { webpack } = require("webpack");
const webpackOptions = require("./webpack.config.js");
const compiler = webpack(webpackOptions);

//开始编译
compiler.run((err, stats) => {
  console.log(err);
  console.log(
    stats.toJson({
      assets: true, //打印本次编译产出的资源
      chunks: true, //打印本次编译产出的代码块
      modules: true, //打印本次编译产出的模块
    })
  );
});

例如:源代码和构建产物的关系如下:

 require函数是在 web环境下加载模块的方法(require原本是node环境中内置的方法,浏览器并不认识require,所以需要手动实现),它接受模块的路径作为参数,返回模块导出的内容。

8-2 核心思想

webpack的原理,核心思想就是如何将左边的源代码变成 dist/main.js

  1. 第一步:根据配置信息(webpack.config.js)找到入口文件(src/index.js)
  2. 第二步:找到入口文件所依赖的模块,并收集关键信息,比如 路径、源代码、所依赖的模块等
    var modules = [
      {
        id: "./src/name.js",//路径
        dependencies: [], //所依赖的模块
        source: 'module.exports = "不要秃头啊";', //源代码
      },
      {
        id: "./src/age.js",
        dependencies: [], 
        source: 'module.exports = "99";',
      },
      {
        id: "./src/index.js",
        dependencies: ["./src/name.js", "./src/age.js"], 
        source:
          'const name = require("./src/name.js");\n' +
          'const age = require("./src/age.js");\n' +
          'console.log("entry文件打印作者信息", name, age);',
      },
    ];
  3. 根据上一步得到的信息,生成最终输出到硬盘中的文件(dist):包括modules对象、require模板代码、入口执行文件等。

在这过程中,由于浏览器并不认识 html、css和js以外的文件格式,所以需要对源文件进行转换——Loader系统

除此之外,打包过程中也有一些特定的时机需要处理,这个时候需要一个可插拔的设计,方便给社区提供可扩展的接口——Plugin系统

Plugin:本质就是一种事件流机制,到了固定的时间节点就广播特定的事件,用户可以在事件内执行特定的逻辑,类似于生命周期。

8-3 架构设计

整个打包过程,主要包含三个阶段:

  • 打包开始前的准备工作
  • 打包过程中(编译阶段)
  • 打包结束后(包含打包成功和打包失败)

webpack源码中,compiler 就是一个大管家,代表上面说的三个阶段,上面挂载着各种声明周期函数,而 compilation负责编译相关工作,也就是打包过程这个阶段。

为了实现这套事件流,借助了 Tapable ,它是一个类似于node.js中的EventEmitter,但更专注于自定义事件的触发和处理。我们可以注册一系列的声明周期函数,在特定的时间点执行。

const { SyncHook } = require("tapable"); //这是一个同步钩子
//第一步:实例化钩子函数,可以在这里定义形参
const syncHook = new SyncHook(["author", "age"]);
//第二步:注册事件1
syncHook.tap("监听器1", (name, age) => {
  console.log("监听器1:", name, age);
});
//第二步:注册事件2
syncHook.tap("监听器2", (name) => {
  console.log("监听器2", name);
});
//第三步:触发事件,这里传的是实参,会被每一个注册函数接收到
syncHook.call("触发事件", "99");

在webpack中,通过tapable在compiler和compilation上挂载着一系列的生命周期Hook,贯穿着整个构建过程

class Compiler {
  constructor() {
    //它内部提供了很多钩子
    this.hooks = {
      run: new SyncHook(), //会在编译刚开始的时候触发此钩子
      done: new SyncHook(), //会在编译结束的时候触发此钩子
    };
  }
}

 

8-4 具体实现

整个实现的过程包含以下步骤:

  1. 搭建结构,读取配置参数
  2. 用配置参数对象初始化 Compiler 对象
  3. 挂载配置文件中的插件Plugin
  4. 执行 Compiler对象的 run 方法开始执行编译
  5. 根据配置文件中的 entry 配置找到所有的入口
  6. 从入口文件出发,调用配置的 Loader 规则,对各模块进行编译
  7. 找出此模块所依赖的模块,再对依赖模块进行编译
  8. 等所有模块都编译完成后,根据模块之间的依赖关系,组装代码块 chunk
  9. 把各个代码块 chunk 转换成一个一个文件加入到输出列表
  10. 确定好输出内容后,根据配置的输出路径和文件名,将文件内容写入到文件系统

第三步:挂载插件

plugin其实就是一个普通的函数,在该函数中需要定制一个 apply 方法,当webpack内部进行插件挂载时会执行apply函数,我们可以在apply函数中订阅各种生命周期钩子,当到达对应时机时执行。

//自定义插件WebpackRunPlugin
class WebpackRunPlugin {
  apply(compiler) {
    compiler.hooks.run.tap("WebpackRunPlugin", () => {
      console.log("开始编译");
    });
  }
}

//自定义插件WebpackDonePlugin
class WebpackDonePlugin {
  apply(compiler) {
    compiler.hooks.done.tap("WebpackDonePlugin", () => {
      console.log("结束编译");
    });
  }
}

插件定义时必须要有一个 apply 方法,加载插件其实就是执行 apply 方法。

//第一步:搭建结构,读取配置参数,这里接受的是webpack.config.js中的参数
function webpack(webpackOptions) {
  //第二步:用配置参数对象初始化 `Compiler` 对象
  const compiler = new Compiler(webpackOptions);
  //第三步:挂载配置文件中的插件
+ const { plugins } = webpackOptions;
+ for (let plugin of plugins) {
+   plugin.apply(compiler);
+ }
  return compiler;
}

第四步:执行 compiler 对象的 run 方法开始执行编译

//Compiler其实是一个类,它是整个编译过程的大管家,而且是单例模式
class Compiler {
  constructor(webpackOptions) {
   //省略
  }
+ compile(callback){
+  //
+ }
+ //第四步:执行`Compiler`对象的`run`方法开始执行编译
+ run(callback) {
+   this.hooks.run.call(); //在编译前触发run钩子执行,表示开始启动编译了
+   const onCompiled = () => {
+     this.hooks.done.call(); //当编译成功后会触发done这个钩子执行
+   };
+   this.compile(onCompiled); //开始编译,成功之后调用onCompiled
  }

定义compilation 大致结构:

class Compiler {
  //省略其他
  run(callback) {
    //省略
  }
  compile(callback) {
    //虽然webpack只有一个Compiler,但是每次编译都会产出一个新的Compilation,
    //这里主要是为了考虑到watch模式,它会在启动时先编译一次,然后监听文件变化,如果发生变化会重新开始编译
    //每次编译都会产出一个新的Compilation,代表每次的编译结果
+   let compilation = new Compilation(this.options);
+   compilation.build(callback); //执行compilation的build方法进行编译,编译成功之后执行回调
  }
}

+ class Compilation {
+   constructor(webpackOptions) {
+     this.options = webpackOptions;
+     this.modules = []; //本次编译所有生成出来的模块
+     this.chunks = []; //本次编译产出的所有代码块,入口模块和依赖的模块打包在一起为代码块
+     this.assets = {}; //本次编译产出的资源文件
+     this.fileDependencies = []; //本次打包涉及到的文件,这里主要是为了实现watch模式下监听文件的变化,文件发生变化后会重新编译
+   }

+   build(callback) {
+    //这里开始做编译工作,编译成功执行callback
+    callback()
+   }
+ }

第五步:根据 entry 找到所有的入口

class Compilation {
  constructor(webpackOptions) {
  // 省略
  }
  build(callback) {
    //第五步:根据配置文件中的`entry`配置项找到所有的入口
+   let entry = {};
+   if (typeof this.options.entry === "string") {
+     entry.main = this.options.entry; //如果是单入口,将entry:"xx"变成{main:"xx"},这里需要做兼容
+   } else {
+     entry = this.options.entry;
+   }
    //编译成功执行callback
    callback()
  }
}

第六步:从入口文件出发,调用配置的 Loader 规则,对各模块进行编译

Loader 本质上就是一个函数,接收资源文件或者上一个Loader产生的结果作为入参,最终输出转换后的结果。

const loader = (source) => {
  return source + "//给你的代码加点注释:loader";
};
// 使用
const { loader } = require("./webpack");
module.exports = {
  //省略其他
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [loader],
      },
    ],
  },
};

第七步:找出此模块所依赖的模块,在对依赖模块进行编译

步骤为:

  • 把源代码转译成 AST
  • 在AST中查找 require 语句,找出依赖的模块名称和绝对路径
  • 将依赖模块的绝对路径 push到 this.fileDependencies 中
  • 生成依赖模块的 模块id
  • 修改语法结构,把依赖的模块改为依赖模块id
  • 将依赖模块的信息push到该模块的dependencies 属性中
  • 生成新代码,并将转译后的源代码放到 module._source 属性上
  • 对依赖模块进行编译
  • 对依赖模块编译完成后得到依赖模块的 module对象,push到 this.modules 中
  • 等依赖模块全部编译完成后,返回入口模块的 module对象
+ const parser = require("@babel/parser");
+ let types = require("@babel/types"); //用来生成或者判断节点的AST语法树的节点
+ const traverse = require("@babel/traverse").default;
+ const generator = require("@babel/generator").default;

//获取文件路径
+ function tryExtensions(modulePath, extensions) {
+   if (fs.existsSync(modulePath)) {
+     return modulePath;
+   }
+   for (let i = 0; i < extensions?.length; i++) {
+     let filePath = modulePath + extensions[i];
+     if (fs.existsSync(filePath)) {
+       return filePath;
+     }
+   }
+   throw new Error(`无法找到${modulePath}`);
+ }

class Compilation {
  constructor(webpackOptions) {
    this.options = webpackOptions;
    this.modules = []; //本次编译所有生成出来的模块
    this.chunks = []; //本次编译产出的所有代码块,入口模块和依赖的模块打包在一起为代码块
    this.assets = {}; //本次编译产出的资源文件
    this.fileDependencies = []; //本次打包涉及到的文件,这里主要是为了实现watch模式下监听文件的变化,文件发生变化后会重新编译
  }

  //当编译模块的时候,name:这个模块是属于哪个代码块chunk的,modulePath:模块绝对路径
  buildModule(name, modulePath) {
    //省略其他
    //6.2.1 读取模块内容,获取源代码
    //6.2.2 创建模块对象
    //6.2.3 找到对应的 `Loader` 对源代码进行翻译和替换

    //自右向左对模块进行转译
    sourceCode = loaders.reduceRight((code, loader) => {
      return loader(code);
    }, sourceCode);

    //通过loader翻译后的内容一定得是js内容,因为最后得走我们babel-parse,只有js才能成编译AST
    //第七步:找出此模块所依赖的模块,再对依赖模块进行编译
+     //7.1:先把源代码编译成 [AST](https://astexplorer.net/)
+     let ast = parser.parse(sourceCode, { sourceType: "module" });
+     traverse(ast, {
+       CallExpression: (nodePath) => {
+         const { node } = nodePath;
+         //7.2:在 `AST` 中查找 `require` 语句,找出依赖的模块名称和绝对路径
+         if (node.callee.name === "require") {
+           let depModuleName = node.arguments[0].value; //获取依赖的模块
+           let dirname = path.posix.dirname(modulePath); //获取当前正在编译的模所在的目录
+           let depModulePath = path.posix.join(dirname, depModuleName); //获取依赖模块的绝对路径
+           let extensions = this.options.resolve?.extensions || [ ".js" ]; //获取配置中的extensions
+           depModulePath = tryExtensions(depModulePath, extensions); //尝试添加后缀,找到一个真实在硬盘上存在的文件
+           //7.3:将依赖模块的绝对路径 push 到 `this.fileDependencies` 中
+           this.fileDependencies.push(depModulePath);
+           //7.4:生成依赖模块的`模块 id`
+           let depModuleId = "./" + path.posix.relative(baseDir, depModulePath);
+           //7.5:修改语法结构,把依赖的模块改为依赖`模块 id` require("./name")=>require("./src/name.js")
+           node.arguments = [types.stringLiteral(depModuleId)];
+           //7.6:将依赖模块的信息 push 到该模块的 `dependencies` 属性中
+           module.dependencies.push({ depModuleId, depModulePath });
+         }
+       },
+     });

+     //7.7:生成新代码,并把转译后的源代码放到 `module._source` 属性上
+     let { code } = generator(ast);
+     module._source = code;
+     //7.8:对依赖模块进行编译(对 `module 对象`中的 `dependencies` 进行递归执行 `buildModule` )
+     module.dependencies.forEach(({ depModuleId, depModulePath }) => {
+       //考虑到多入口打包 :一个模块被多个其他模块引用,不需要重复打包
+       let existModule = this.modules.find((item) => item.id === depModuleId);
+       //如果modules里已经存在这个将要编译的依赖模块了,那么就不需要编译了,直接把此代码块的名称添加到对应模块的names字段里就可以
+       if (existModule) {
+         //names指的是它属于哪个代码块chunk
+         existModule.names.push(name);
+       } else {
+         //7.9:对依赖模块编译完成后得到依赖模块的 `module 对象`,push 到 `this.modules` 中
+         let depModule = this.buildModule(name, depModulePath);
+         this.modules.push(depModule);
+       }
+     });
+     //7.10:等依赖模块全部编译完成后,返回入口模块的 `module` 对象
+     return module;
   }  
  //省略其他
}

第八步:组装代码块 chunk

每个入口文件会对应一个代码块chunk,每个代码块chunk里面会放着本入口模块和他依赖的模块(暂不考虑代码分割)。

第九步:把代码chunk转换成一个个文件加入到输出列表:

这一步需要结合配置文件中的output.filename去生成输出文件的文件名称,同时还需要生成运行时的代码。

//生成运行时代码
+ function getSource(chunk) {
+   return `
+    (() => {
+     var modules = {
+       ${chunk.modules.map(
+         (module) => `
+         "${module.id}": (module) => {
+           ${module._source}
+         }
+       `
+       )}  
+     };
+     var cache = {};
+     function require(moduleId) {
+       var cachedModule = cache[moduleId];
+       if (cachedModule !== undefined) {
+         return cachedModule.exports;
+       }
+       var module = (cache[moduleId] = {
+         exports: {},
+       });
+       modules[moduleId](module, module.exports, require);
+       return module.exports;
+     }
+     var exports ={};
+     ${chunk.entryModule._source}
+   })();
+    `;
+ }

第十步:输出内容

将文件写入文件系统:

class Compiler {
  constructor(webpackOptions) {
    this.options = webpackOptions; //存储配置信息
    //它内部提供了很多钩子
    this.hooks = {
      run: new SyncHook(), //会在编译刚开始的时候触发此run钩子
      done: new SyncHook(), //会在编译结束的时候触发此done钩子
    };
  }
  //第四步:执行`Compiler`对象的`run`方法开始执行编译
  run(callback) {
    this.hooks.run.call(); //在编译前触发run钩子执行,表示开始启动编译了
    const onCompiled = (err, stats, fileDependencies) => {
+     //第十步:确定好输出内容之后,根据配置的输出路径和文件名,将文件内容写入到文件系统(这里就是硬盘)
+     for (let filename in stats.assets) {
+       let filePath = path.join(this.options.output.path, filename);
+       fs.writeFileSync(filePath, stats.assets[filename], "utf8");
+     }
+     callback(err, {
+       toJson: () => stats,
+     });
      this.hooks.done.call(); //当编译成功后会触发done这个钩子执行
    };
    this.compile(onCompiled); //开始编译,成功之后调用onCompiled
  }
}

 

posted @ 2021-08-10 19:13  聂丽芳  阅读(87)  评论(0)    收藏  举报