webpack系列(三) -- 深入拓展

89 阅读12分钟

1. 源码位置【source-map】

简单来讲: 当前我们的代码都经过压缩、编译、转译后生成的,和实际开发时的代码对比一看面目全非。那么当我们跑代码时,报错都报的转换后的代码,那怎么看到我们的原始代码?这时候就需要 我们的主角 source-map 了。在webpack中,可以通过devtool选项来配置Source Map。

详细可见: JavaScript Source Map 详解 --- 阮一峰

举个例子,这是我业务代码中『故意』写的错误

  • 当然有经验的一看就知道是 ok 前面的对象为空值了,直接跑源码中搜索 .ok ,再找到前面的对象,要是逻辑正确就加个?.完事~
  • 但对于很多情况是无法通过经验找到的,当我们想去看到底哪里出错的时候,这个就会让人异常头疼,因此就需要使用 source-map

我们可以通过配置 devtool 参数获取不同形式的 source-map

devtool打包结果代码行数显示正确
default / eval会打包成字符串 并使用 eval执行
source-map会打包文件同时生成对应的 map 文件【会带上行号和列号】
hidden-source-map会打包文件同时生成对应的 map 文件
inline-source-map在打包产出 bundle.js 最后加上 DataURL 形式的 sourcemap 信息
eval-source-map会在eval打包产出的字符串中 补上 sourceURL信息
cheap-source-map会打包文件同时生成对应的 map 文件【只会带上行号】
cheap-module-source-map会打包文件同时生成对应的 map 文件【只会带上行号】
+ 显示 babel-loader 转译前的代码
cheap-module-eval-source-map生成字符串打包到bundle中,无多余mapper文件 其他同上

总结一下:

  • 对于前端使用babel转译loader的时候,且基本只需关心当前行数,因此使用最推荐的 cheap-module-eval-source-map 选项
  • 对于生成环境 production 不应该配置 devtools 选项,因为不应该暴露给别人看到你的源码信息。且得到更小的体积

2. 代码拆分

将可复用代码,拆分出来减少不必要的打包 或 对第三方代码库进行拆分,减少传输资源的浪费。

可能会对后半句话有点费解,那先抛出3个可能想问的问题,后面进行一一解答。

问题一:

当前有个文件引入 lodash 我们只引了其中的 _.isEmpty 方法,那么请问我们只会抽取 lodash 的部分代码进行打包还是全打包呢?

当前虽然只在文件中引入了一个方法,但我们执行打包命令后会发现,最终实际上会把 lodash 都打包进来,这完全是不必要的。

image-20220423221758756

问题二:

由上一个实验得出,若单入口文件中引入 lodash ,则会被完全打包。那要是当前为多入口且文件中都引入了 lodash ,我们同时对其进行打包,请问会打包几份 lodash 代码呢?

我们先进行双入口的引入举例,一般我们的SPA都只需要单入口。

webpack.config.js

// 多入口文件
// 对于多入口形式肯定无法使用字符串形式指定 - 数组 / 对象形式
module.exports = {
  	// entry: './index.js',
    // entry: ['./index.js', './index2.js'],	// 数组形式、将两者打包到一个文件下
  	entry: {
        a: "./index.js",											// 对象形式、将文件打包到多个文件下,且对应的name就是当前设置的key
        b: "./index2.js"
    },
    output: {
      	filename: '[name].js',	// 对于多文件bundle肯定就不能打包到一个文件中了,因此可以使用通配符形式
    },
};

image-20220424102255850

通过观察最终打包出来的文件大小发现:当我们指定多个打包文件,这些文件都会自己打包一份 lodash ,实际上这显然是没有必要的

问题三:

当我将 lodash 进行抽离后,对整体项目有什么好处呢?

抽离肯定是减少包体积啦,有多种减少体积方式。添加 dependon 的形式、自动判别式

1、先来看看 dependon 的形式,通过指名每个文件所依赖的库名【可自定义库或第三方库】

webpack.config.js

module.exports = {
    entry: {
        a: {
            import: "./index.js",
            dependOn: "lodash"
        },
        b: {
            import: "./index2.js",
            dependOn: "lodash"
        },
        lodash: "lodash"
    }
    ...
};

image-20220424113652101

发现最后会打包出3个文件 a.jsb.jslodash.js

我们发现 对于我们自定义文件的 a.jsb.js 实际打包后体积很小!lodash 还是一样那么大,但只有一份。其实此时就很符合我们的要求了

问题四:

这样太麻烦了,需要手动指定文件,要是引入第三方库无数,我全要这样加吗?

SplitChunksPlugin 在webpack5版本已经内置,会自动将文件进行拆分,对于大部分用户来说非常友好。

默认情况下,它只会影响到按需加载的 chunks,因为修改 initial chunks 会影响到项目的 HTML 文件中的脚本标签。

webpack 将根据以下条件自动拆分 chunks:

  • 新的 chunk 可以被共享,或者模块来自于 node_modules 文件夹
  • 新的 chunk 体积大于 20kb(在进行 min+gz 之前的体积)
  • 当按需加载 chunks 时,并行请求的最大数量小于或等于 30
  • 当加载初始化页面时,并发请求的最大数量小于或等于 30

webpack.config.js

module.exports = {
    // entry: ['./index.js', './index2.js'],	// 数组形式、将两者打包到一个文件下
  	entry: {
        a: "./index.js",											// 对象形式、将文件打包到多个文件下,且对应的name就是当前设置的key
        b: "./index2.js"
    },
  	optimization: {
				...
        splitChunks: {
            chunks: 'all'
        }
    },
    ...
};

1

此时我们并没有手动进行配置,但它能够将我们引入的共有文件进行抽离。对于没有特殊要求的分离chunks时,这显然是最优选择。

问题五:

有了这个,为什么还要之前的配置呢?单个包可以抽离出来,多个包能抽离吗?抽离出来什么样的呢?

做个实验,还是使用这个方式, a.jsb.js 都同时引入 lodashmoment

image-20220424123306967

我们发现最终会将第三库都打包到一个特定js文件中,其他无关的业务代码还是放在一个文件中。因此其实对于平时不关心打包结果时,这种将所有第三方库都打包的方式是不错的。但有时可能需要像 问题三 一样需要单独抽离文件叫特定名【即 lodashlodash.xx.jsmomentmoment.xx.js 则需要使用 dependOn 这种方法了】

问题六:

发现一直会有大小限制 244KB,显然这是webpack希望我们的包不超过这个限制,明明我们的代码那么少,一引入第三方库就会超标,那是不是我们就不能引入了呢?有什么办法能够规避吗?

第一种方法当然就是关闭警告啦~但这个显然在掩耳盗铃

第二种方式就是进行 tree-shaking 显然很多 lodash 的方法是无用的,不应该一起被打包

这就引出我们下一章 Tree-shaking

3. Tree-shaking

用于移除 JS 上下文中未引用过的代码,以减小代码体积,间接减少代码在网络请求过程中的耗时。

  • 对于 webpack 4 来说,是默认将所有的包/文件 都进行打包【无Tree-shaking】。

  • 对于 webpack 5 来说已经默认内置了,得益于 optimization.providedExports 被默认启用。需要注意的是 tree shaking 依赖于 ES6 的模块语法 —— importexport。简单来说就是对你 import 的代码进行静态分析,如果发现没有被用到的部分就不再 export 从而实现实现Tree-shaking

由此看来我们当前使用的就是最优解了,为什么上面的例子中还会报错出现问题呢?

那是因为 lodash 的问题。NPM 包提供出去的代码得是 ESM !因此这并不是我们能判断和操控的,当然你也可以使用 lodash-es 模块。

// ESM[ES Modules]
import foo from 'foo';
export const bar = foo;
export default bar;

// CJS[CommonJS]
const foo = require('foo');
module.exports = foo;

但我们知道不可能所有的包都有特有的 -es 版本【基本都是以 umd 规范 - (commonJS + AMD)打包】,因此对于我们需要压缩打包体积来说只能另辟蹊径 使用代码压缩

4. 代码压缩

当前整体的打包和各种文件的配置方式我们都已经熟悉了,实际上已经可以初步使用了。可往往当我们直接使用 webpack 就要对代码进行打包时,会发现文件相当的大,这时就需要将我们的js产出物进行压缩处理了

optimization.minimizer 顾名思义就是用来压缩代码的啦。

  • webpack 5 中已经内置了 terser-webpack-plugin 插件【该插件使用 terser 来压缩 JavaScript】其实压缩效率和质量都是想当高的,除非有些特殊定制化的需求,否则使用默认即可。
  • 注意 terser-webpack-plugin 只能压缩 js 代码,css 还是需要其相应的 CssMinimizerPlugin 进行处理,在上面也有提到过。[css族](#8.1 css族)

webpack.config.js

module.exports = {
    ...
    optimization: {
        minimizer: [
            // 在 webpack@5 中,你可以使用 `...` 语法来扩展现有的 minimizer(即 `terser-webpack-plugin`)
            // 这是一个内置插件,可以极大压缩js文件
            '...',
            new CssMinimizerPlugin(),	// 压缩css文件
        ],
    },
		...
};

综上,当我们没有特定情况时,直接使用默认推荐 JS 的压缩效率就是最佳情况,但别忘记了 css 的压缩。至此又简单又高效的压缩代码方式就学会了。

此时我们来看看之前打包 lodash 的库经过压缩后的结果

image-20220424162415328

虽然这个压缩结果很喜人,但都知道其实我只是引入 lodash 一个方法而已,就要全部打包,有没有什么方法能让我完全摆脱这个负重?因为往往做工具库/组件库的都希望小小的,别人引用起来没有负担。此时 Externals 就出现了。

5. 外部扩展(Externals)

官网介绍:externals 配置选项提供了「从输出的 bundle 中排除依赖」的方法。相反,所创建的 bundle 依赖于那些存在于用户环境(consumer's environment)中的依赖。防止将某些 import 的包(package)打包到 bundle 中,而是在运行时(runtime)再去从外部获取这些扩展依赖(external dependencies)。

简单来说就是,将当前依赖的 lodash 不默认打包进 bundle 而是将其下放到引用你打包文件的环境,要求使用者提供 lodash 。实话实说,这个对于工具库 library 来说着实是福音了,因为使用者大概率是二次开发,也大概率会使用到你 external 出去的库,因此无需多次打包使用从而减少开发者与使用者的打包体积。

webpack.config.js

module.exports = {
    //...
    externals: {
      	jquery: 'jQuery',
      	moment: 'moment',
        lodash: {
            commonjs: 'lodash',
            commonjs2: 'lodash',
            amd: 'lodash',
            root: '_',
        },
    },
};

此时我们就将 jQuery moment lodash 放在外部依赖了。那肯定有小伙伴问 这写法为什么不一样呢?

那是为了限制我们打包出来的文件运行于不同的环境下,具有外部依赖可以在各种模块上下文(module context)中使用,例如 CommonJS, AMD, 全局变量和 ES2015 模块,外部 library 可能是以下任何一种形式:

  • 字符串形式:代表下面所有都需要兼容
  • root:可以通过一个全局变量访问 library(例如,通过 script 标签)。
  • commonjs:可以将 library 作为一个 CommonJS 模块访问。
  • commonjs2:和上面的类似,但导出的是 module.exports.default.
  • amd:类似于 commonjs,但使用 AMD 模块系统。

至此对于我们的优化打包代码就讲这么多,还有什么可以优化的点呢?缓存是我们经常提及的技术...

6. 缓存

对于浏览器缓存来说,一些不是一直变更的大资源则应当被缓存起来,无需向服务器请求获取。浏览器会按照请求的文件名判断是否相同来决定是否使用缓存。

因此针对这个特性,我们需要做的就是将代码进行 [代码拆分](#11. 代码拆分) 并将一直不变的代码名设置为相同的,将变化的文件名设置为不同,即打包出的文件名会按照文件内容进行生成。

webpack.config.js

module.exports = {
    //...
    optimization: {
        splitChunks: {
            cacheGroups: {
								vendor: {
                    test: /[\\/]node_modules[\\/]/,
                    name: 'vendors',
                    chunks: 'all',
                },
            },
        },
    },
};

也很简单是将 node_modules 下的所有文件打包到 vendor 文件中。这也是 webpack 的默认选项。

对于我们自己的代码来说 可以使用 [代码拆分](#11. 代码拆分) ,再进行输出文件名控制,保证文件名会按照内容进行命名

webpack.config.js

module.exports = {
    ...
  	output: {
        filename: "[name].[contenthash].js",
				...
    },
};

7. 打包工具库

当前我们想要导出了一些工具函数给外部使用,通过上面的学习我们知道,对于没有使用的函数会被 tree-shaking 掉,显然工具函数对外暴露的都不会被引用,这时我们应该怎么办呢?

配置 output.library

webpack.config.js

module.exports = {
		...
    output: {
        filename: "[name].js",
        path: path.resolve(__dirname, "./dist"),
        // library: 'mylibrary',		只支持script方式引入,注入到window对象上
        library: {
            name: 'mylibrary',
            type: 'umd'|'commonjs'|'window',
        }
    }
    ...
}

我们先前有介绍过 umd模块 = CommonJS + AMD、对于我们的esm模块咋办呢?

其实目前只有实验性功能 打包 esm,目前对于 umd 除了下面的模式,其他都兼容

<script type="module">
	import {add} from 'lodash'
	add(1, 2)
</script>

对于commonJS模块来说,我们肯定是可以在node环境下运行的,可往往我们执行时会出现下面这个问题

image-20220425201839356

这是因为 output.globalObject 默认是 self 属性,但为了使 UMD 构建在浏览器和 Node.js 上均可用,应将 output.globalObject 选项设置为 'this' 即可

webpack.config.js

module.exports = {
		...
    output: {
        filename: "[name].js",
        path: path.resolve(__dirname, "./dist"),
        library: {
            name: 'mylibrary',
            type: 'umd,
        },
      	globalObject: 'this'
    }
    ...
}