likes
comments
collection
share

(中篇)Webpack5优化构建速度、构建产物

作者站长头像
站长
· 阅读数 1

前言

作为一个前端工程师,前端工程化是必过的坎,而Webpack在前端工程化中扮演着至关重要的角色。 要明白webpack为什么重要,就要知道它为什么出现,解决了什么问题。

前端模块化从文件划分模块、命名空间划分模块、IIFE通过约定来实现模块化,到CommonJS、ES Module通过行业规范实现模块化,如今ES Module已经是最正统最主流的模块化规范,但是它依然还存在兼容问题,所以开发者还需要解决兼容问题。而且模块化开发,会划分出很多文件,每个文件就是一个模块,文件过大,浏览器请求、加载文件时间过长,影响页面渲染速度,文件过多浏览器请求频繁,也影响性能,所以需要对这些文件进行合并拆分。而项目复杂后,html、css、图片、字体文件等也需要模块化来管理。

于是webpack顺势而出,它是一个现代化的模块打包工具,支持js、css等不同种类资源的模块化(项目中使用的每个文件都是一个模块),同时对这些资源做兼容性处理,最后对这些资源文件根据需要做拆分合并压缩后打包为静态资源

(中篇)Webpack5优化构建速度、构建产物

首先要做的一步,当然是尽可能使用最新的webpack。

webpack文档中有专门的一块内容讲构建性能

查看打包时间和打包体积

安装 speed-measure-webpack-pluginpnpm add -D speed-measure-webpack-plugin,但是这个插件不兼容一些新版插件,比如mini-css-extract-plugin,打包会报错,要用只能将不兼容的插件降版本。

安装 webpack-bundle-analyzer 分析打包体积

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

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

一、优化构建速度

1、提高模块解析速度

resolve.alias

当在项目里面使用 import 或 require相对路径,引入的资源文件所处的目录,被认为是上下文目录。在 import/require 中给定的相对路径,会拼接此上下文路径,来生成模块的绝对路径。 配置别名alias,确保模块引入变得更简单,减少webpack的路径拼接查找时间, 从

import Utility from '../../utilities/utility';

改成

const path = require('path');
module.exports = {
  //...
  resolve: {
    alias: {
      Utilities: path.resolve(__dirname, 'src/utilities/'),
    },
  },
};
import Utility from 'Utilities/utility';

resolve.extensions

配置 resolve.extensions,webpack 会按顺序解析列在数组首位的后缀的文件并跳过其余的后缀,将使用频率高的文件放前面,可以加快webpack解析速度;

    resolve: {
      extensions: [".vue", ".ts", ".scss", "..."],
    },

最好的实践,还是尽量带上后缀,减少匹配过程,加快解析速度

resolve.mainFields

exports field is preferred over other package entry fields like mainmodulebrowser or custom ones.

(中篇)Webpack5优化构建速度、构建产物 如果不能通过npm包的package.json里面的exports字段直接找到入口文件,或者没有exports字段 可以修改一下 resolve.mainFields,web端默认 ['browser', 'module', 'main'],node端默认['module', 'main'] ,根据情况,看项目使用esm还是cjs模块化方案,调整mainFields的顺序,加快查找速度

module.exports = {
  //...
  resolve: {
    mainFields: ['main','module', 'browser'],
  },
};

resolve.modules

如果在项目中使用较多模块路径,比如import math.js from 'utils',可以配置 resolve.modules,它默认是['node_modules'],webpack 查找当前目录以及祖先路径(即 ./node_modules../node_modules 等等)

如果有很多模块路径来自src,可以优先搜索src,

module.exports = {
  //...
  resolve: {
    modules: [path.resolve(__dirname, 'src'), 'node_modules'],
  },
};

2、不需要解析的模块 module.noParse

项目中如果用了类似jquery或者loadsh这种库,没有使用AMD/CommonJs规范,没有模块化,就可以使用noparse排除解析,因为它们两个没有其他依赖,

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

排除的文件里面不应该含有 importrequiredefine 的调用,或任何其他导入机制,打包后浏览器不兼容,就报错; 比如我安装了loadsh-es,它里面是使用了import引入其他文件,如果不解析它,就会报错

3、排除多余模块 IgnorePlugin

有些npm包,模块被捆绑在了一起,比如webpack官网的例子,moment这个包,我只需要zh-cn这个模块,不需要其他国家的语言模块。

(中篇)Webpack5优化构建速度、构建产物

new webpack.IgnorePlugin({
  resourceRegExp: /^./locale$/,
  contextRegExp: /moment$/,
});

(中篇)Webpack5优化构建速度、构建产物

4、减少loader处理文件

使用loader的时候,使用include和exclude缩小查询范围,exclude优先级更高,使用include包含更精确范围

const resolve = (dir: string) => path.resolve(__dirname, dir);

  {         
         test: /\.vue$/,
          include: resolve("./src"),
          loader: "vue-loader"
      },

5、使用多进程构建

多余耗时的loader,像babel-loader可以使用 thread-loader每个 worker 都是一个独立的 node.js 进程,其开销大约为 600ms 左右。同时会限制跨进程的数据交换。

请仅在耗时的操作中使用此 loader!

module.exports = {
  module: {
    rules: [
      {
        test: /.js$/,
        include: path.resolve('src'),
        use: [
          "thread-loader",
          "babel-loader" // 耗时的 loader (例如 babel-loader)
        ],
      },
    ],
  },
};

可以通过预警 worker 池来防止启动 worker 时的高延时。

这会启动池内最大数量的 worker 并把指定的模块加载到 node.js 的模块缓存中。

const threadLoader = require('thread-loader');

threadLoader.warmup(
  {
    // 池选项,例如传递给 loader 选项
    // 必须匹配 loader 选项才能启动正确的池
    workers: 2,
    // 一个 worker 进程中并行执行工作的数量
    workerParallelJobs: 50,
    // 闲置时定时删除 worker 进程
    poolTimeout: 2000 //(默认500ms),
  },
  [
    // 加载模块
    // 可以是任意模块,例如
    'babel-loader',
    'babel-preset-es2015',
    'sass-loader',
  ]
);

6、缓存

在 webpack 配置中使用 cache 选项实现持久化缓存。 缓存生成的 webpack 模块和 chunk,来改善构建速度。cache 会在开发模式被设置成 type: 'memory'内存缓存, 而且在生产模中没有默认开启。 cache: true 与 cache: { type: 'memory' } 配置作用一致

module.exports = {
  cache: {
    type: "filesystem",
    buildDependencies: {
      // This makes all dependencies of this file - build dependencies
      config: [__filename],
    },
  },
};

二、优化开发体验

1、source map

浏览器运行的代码通常是压缩转换过的,这样可以节约文件下载时间,但是debug就困难了,出现问题,不好准确定位到报错行,source map就是一个将压缩转换后的代码映射到原始代码的文件,它能够让浏览器重新构建原始代码文件,并把情况展示在console面板这样的调试器中。

要做到这一点:

  • 生成source map文件
  • 并且在转换后的代码文件底部添加特殊注释指向source map文件,注释的语法格式:
//# sourceMappingURL=main.c8400759.js.map

同时在chrome浏览器中,(已经默认勾选),必须将 devtool->sources->settings->preference->ennable javascript source maps勾选上,浏览器才会下载source map文件进行解析映射。

(中篇)Webpack5优化构建速度、构建产物

使用webpack的devtool选项控制是否生成,以及如何生成 source map。

对于开发环境以下选项非常适合:

  • eval - 每个模块都使用 eval() 执行,并且都有 //# sourceURL。此选项会非常快地构建。主要缺点是,由于会映射到转换后的代码,而不是映射到原始代码(没有从 loader 中获取 source map),所以不能正确的显示行数。
  • eval-source-map - 每个模块使用 eval() 执行,并且 source map 转换为 DataUrl 后添加到 eval() 中。初始化 source map 时比较慢,但是会在重新构建时提供比较快的速度,并且生成实际的文件。行数能够正确映射,因为会映射到原始代码中。它会生成用于开发环境的最佳品质的 source map。
  • eval-cheap-source-map - 类似 eval-source-map,每个模块使用 eval() 执行。这是 "cheap(低开销)" 的 source map,因为它没有生成列映射(column mapping),只是映射行数。它会忽略源自 loader 的 source map,并且仅显示转译后的代码,就像 eval devtool。
  • eval-cheap-module-source-map - 类似 eval-cheap-source-map,并且,在这种情况下,源自 loader 的 source map 会得到更好的处理结果。然而,loader source map 会被简化为每行一个映射(mapping)。

在大多数情况下,最佳选择是 eval-cheap-module-source-map

三、优化构建产物

1、代码分割

代码分割是 webpack 中最主要的特性之一。此特性能够把代码分离到不同的 bundle 中,然后便能按需加载或并行加载这些文件。代码分离可以用于获取更小的 bundle、控制资源加载优先级,如果使用合理,会极大减小加载时间,提升页面加载速度。

常用的代码分离方法有三种:

  • 多个入口:使用 entry 配置手动地分离代码。
  • 防止重复:使用 入口依赖 或者 SplitChunksPlugin 去重和分离 chunk。
  • 动态导入:通过模块的内联函数调用分离代码。

(1)多个入口

module.exports = {
  //...
  entry: {
    index: './src/index.ts',
    home: './src/home.ts',    
  },
};

此时output.filename不能写死,不然会报错,多个入口,无法对应出口文件,可以写成以下方式:

module.exports = {
  //...
  output: {
    // 使用入口名称
    filename: '[name].bundle.js',
    // 使用内部 chunk id
    // filename: '[id].bundle.js',
    // 使用由生成的内容产生的 hash
    // filename: '[contenthash].bundle.js'
    // 结合多个替换组合使用
    // filename: '[name].[contenthash].bundle.js'
    // 使用函数返回 filename
    // filename: (pathData) => {
     // return pathData.chunk.name === 'main' ? 
     // '[name].js' : '[name]/[name].js';
    },
  },
};
(中篇)Webpack5优化构建速度、构建产物 (中篇)Webpack5优化构建速度、构建产物

但是这种方式有隐患:如果index.ts和home.ts都引入了lodash-es这个库,

// index.ts
import { join } from "lodash-es";
console.log(join(["Another", "module", "loaded!"], " "));

// home.ts
import { join } from "lodash-es";
console.log(join(["Another", "module", "loaded!"], " "));

join函数会重复打包,在配置文件中配置 dependOn 选项,以在多个 chunk 之间共享join模块。同时设置 optimization.runtimeChunk : 'single' ,创建一个在所有生成 chunk 之间共享的运行时文件,join模块只能实例化一次。这种保证允许模块的顶级作用域用于全局状态,并在join模块的所有使用者之间共享

module.exports = {
  entry: {
    index: {
      import: "./src/index.ts",
      dependOn: "shared"
    },
    home: {
      import: "./src/home.ts",
      dependOn: "shared"
    },
    shared: ["lodash-es"]
  },
  output: {
    filename: "[name].[contenthash:8].js"
  },
  optimization: {
    runtimeChunk: "single"
  }
};

(2)import() 动态导入

// index.ts
const btnEl = document.createElement('button')
btnEl.textContent = 'import导入'
btnEl.onclick=function(){
  import("./home").then((res) => {
    res.sendName();
  });
}
document.body.appendChild(btnEl)

// home.ts
export const sendName = () => {
  console.log("dynamic import");
};

home.ts会被单独打包成一个bundle文件,点击按钮的时候,才会创建一个script标签去请求它,做到按需加载

(中篇)Webpack5优化构建速度、构建产物

在使用vue-router或者react-router路由懒加载时,就是这个原理,依赖于打包工具的切割

(3)optimization.splitChunks

webpack v4+ 开始提供的全新的通用分块策略,配置 optimization.splitChunks,默认只对按需加载的chunk分包,比如import(),如果想要分更多包,就要配置optimization.splitChunks

默认配置:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      // 默认只将异步import()引入代码分割,
      chunks: "async",
      // 最小20kb才会分割
      minSize: 20000,
      // 除了满足minsize,还要减少主chunk的大小才会分割
      minRemainingSize: 0,
      // 拆分前必须共享模块的最小chunks数
      minChunks: 1,
      // 按需加载时的最大并行请求数
      maxAsyncRequests: 30,
      // 入口文件的最大并行请求数
      maxInitialRequests: 30,
      enforceSizeThreshold: 50000,
      // 缓存组可以继承和/或覆盖来自 splitChunks.* 的任何选项
      // test、priority 和 reuseExistingChunk 只能在缓存组级别上进行配置。
      // 可以配置多个组,如果一个模块满足多个组条件,最终由priority决定打包到哪个组
      cacheGroups: {
        // 默认将所有来自node_modules目录的模块打包至vendors组
        defaultVendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          // 如果当前 chunk 包含已从主 bundle 中拆分出的模块,
          // 则它将被重用,而不是生成新的模块。这可能会影响 chunk 的结果文件名。
          reuseExistingChunk: true
        },
        // 两个以上的chunk所共享的模块打包至default组
        default: {
          minChunks: 2,
          // 优先级没有defaultVendors高
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  }
};

根据项目不同情况配置

  optimization: {
    usedExports: true,
    splitChunks: {
      cacheGroups: {
        // 包括整个应用程序中 node_modules 的所有代码。
        vendor: {
          name: "vendor",
          chunks: "all",
          priority: 20,
          test: /[\\/]node_modules[\\/]/
        },
        // 包括入口(entry points)之间所有共享的代码
        commons: {
          name: "commons",
          chunks: "initial",
          priority: 10,
          minSize: 0,
          // 至少被两个chunk共享才分离
          minChunks: 2
        }
      }
    }
  }

(4)预获取和预加载

// home.ts
export const sendName = () => {
  console.log("dynamic import");
};

// index.ts
const btnEl = document.createElement("button");
btnEl.textContent = "import导入";
btnEl.onclick = function () {
  import(/* webpackPrefetch: true */ "./home").then((res) => {
    res.sendName();
  });
};
document.body.appendChild(btnEl);

上面的代码在构建时会生成 <link rel="prefetch" href="./home.ts"> 并追加到页面头部,指示浏览器在闲置时间预获取 home.ts 文件,不要等点击按钮再获取,优化用户体验。

import(/* webpackPreload: true */ "./home").then((res) => {
    res.sendName();
  });

preload具有更高优先级,home.ts会和index.ts并行下载,但不会解析执行

2、tree-shaking

sideEffects 和 usedExports(更多地被称为 tree shaking)是两种不同的优化方式

sideEffects 更为有效 是因为它允许跳过整个模块/文件和整个文件子树。

usedExports 依赖于 terser 检测语句中的副作用。它是一个 JavaScript 任务而且不像 sideEffects 一样简单直接。并且由于规范认为副作用需要被评估,因此它不能跳过子树/依赖项。

一种方式是在package.json里面配置sideEffects字段:

所有代码都没有副作用,直接设置false,

"sideEffects": false

但是这样做,在首页引入的 css文件也不会被打包,换成数组方式

  "sideEffects": [
    "*.css"
  ]

还有一种方式,在生产模式下打包,webpack会自动tree-shaking,相当于配置

module.exports = {
  // ...
  optimization: {
    usedExports: true,
  },
}

同时可以使用/*#__PURE__*/ 注释放到函数调用之前,用来标记此函数调用是无副作用的。

要对css做tree-shaking,安装插件 purgecss-webpack-plugin

const path = require("path");
const { globSync } = require("glob");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const PurgeCSSPlugin = require("purgecss-webpack-plugin");

module.exports = {
  //...
  module: {
    rules: [
      {
        test: /\.s?css$/,
        use: [
          MiniCssExtractPlugin.loader,
          "css-loader",
          "postcss-loader",
          "sass-loader"
        ]
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: "style/[name]_[contenthash:8].css",
      chunkFilename: "style/[name]_[chunkhash:8].css"
    }),
    new PurgeCSSPlugin({
      paths: globSync(`${path.resolve(__dirname, "src")}/**/*`, { nodir: true })
    })
  ]
};

3、代码压缩

webpack5 在生产环境下已经使用 terser-webpack-plugin 自动对js代码做了压缩,如果要自定义配置,那么仍需要安装 terser-webpack-plugin

同时也发现在生产模式下css代码也是被压缩了的,不用在手动的配置 css-minimizer-webpack-plugin,而且被注释的css代码也被删除了

总结

Webpack在前端工程化中提供的能力:

  1. 模块打包和依赖管理:Webpack可以将前端应用程序拆分为多个模块,并通过各种加载器(Loaders)和插件(Plugins)来处理和转换这些模块。它可以解析模块之间的依赖关系,并生成一个或多个打包后的文件,以供浏览器加载和执行。
  2. 资源管理和优化:Webpack不仅可以打包JavaScript模块,还可以处理其他类型的静态资源,如样式表(CSS、Sass、Less)、图片、字体等。通过加载器和插件,它可以对这些资源进行压缩、合并、优化和缓存等处理,以提高应用程序的加载性能和用户体验。
  3. 代码分割和懒加载:Webpack支持代码分割和懒加载,可以将应用程序代码拆分为多个块(chunks),并按需加载这些块。这种方式可以减小初始加载的文件大小,提高页面的加载速度,并实现按需加载,降低了用户首次访问时的等待时间。
  4. 开发环境和生产环境的配置:Webpack提供了强大的配置能力,可以根据开发环境和生产环境的需求来进行不同的配置。它支持开发服务器、热模块替换(Hot Module Replacement)、代码调试等功能,使开发人员能够更高效地进行开发和调试。
  5. 构建流程的自动化:Webpack可以与其他构建工具(如Grunt、Gulp)集成,并通过配置文件定义整个构建流程。它可以自动化处理资源依赖关系、编译、压缩、合并和输出最终的生产代码,简化了前端开发的构建过程,提高了开发效率。

兄弟篇

转载自:https://juejin.cn/post/7322518839522590747
评论
请登录