最近某个页面在某次发布后,引用的一个第三方包发生了运行时报错。经过一番仔细的 DEBUG,发现在这次发布的构建中,lodash
的 _baseFlatten
模块被替换成了另一个一行代码的模块(后面确认是 lodash
的 head
模块)。
同一个 git commit (而且有 lock file)二次打包就不复现了。(为什么两次打包结果,而且被替换的情况很难复现,仍然是一个谜)。
后面经过其他有相关经验的同事的提醒,知道大概率和 lodash-webpack-plugin 这个插件有关系。以前只是粗浅地使用 lodash-webpack-plugin (以及 babel-plugin-lodash),对于它们具体做了什么不甚了解。
于是花了一些时间研究了一下,发现这玩意(lodash-webpack-plugin)的坑挺大的。这篇文章将主要分以下部分:
不要使用 lodash-webpack-plugin,坑太大了。
如果没有耐心看完全文,可以直接看后文给的示例。
目前的 Lodash 版本号为 4,官方提供了 lodash 和 lodash-es 两个 NPM 包。
lodash.min 的整体体积为 71K (gzip 后 25K),稍微有一点大,通常前端项目只会使用到其中部分方法,如果整个引入会显得有点浪费。很容易能想到的办法是:只打包使用到的部分。
// 整个 lodash 都会被打包,压缩后 72K
import { head } from 'lodash';
head([1,2,3]
如果你选择 lodash-es,使用不是特别古老的打包器进行打包,得益于 es module 的静态性带来的 tree-shacking 能力,打包文件的体积会明显小于整个 lodash/lodash-es:
// 打包压缩后 1K
import { head } from 'lodash-es';
head([1,2,3]
如果是 lodash,你可以选择手动按需引入:
// 打包压缩后 1K
import head from 'lodash/head';
head([{id: 1}]);
另外,如果你倾向于 import { head } from 'lodash'
的写法,而且想继续使用 lodash,又需要按需加载的能力,可以引入 babel-plugin-lodash
插件,它的作用就是帮你把你的 import 写法自动转化成按需加载的形式。
这个 babel 插件做的事件从结果上说比较简单,就是做了下面的转化:
import { head } from 'lodash';
// 或者
import _ from 'lodash';
// +
_.head(...)
// 转化为↓↓↓
import head from 'lodash/head';
head(...)
然而,现实往往不是很理想,在实际使用中你会发现,只引入了 lodash 的几个方法,从数量上看还不到总量的零头,但是好像 lodash 的一大半都被打包进去了。
这并不是错觉,以大家都熟悉的 map
为例(但是并不推荐使用,请使用语言内置的 map):
import map from 'lodash/map'
map()
压缩后体积 32K,快一半 lodash 的大小了。简单分析一下,会发现它一共打包了 121 个 lodash 模块。
刚看到这个结果的时候肯定会非常惊讶,因为在大家印象中 map
的实现应该很简单,几行代码就能搞一个出来。
进一步查看文档和源码,会发现 _.map
的功能远比我们想象的复杂,这里举几个例子:
_.map({ key1: 1, key2: 2 }, x => x) // [1, 2]
_.map([
{ id: 1, age: 12 },
{ id: 1, age: 13 },
{ id: 1, age: 12 },
], { age: 12 }) // [true, false, true]
_.map([{a: {b: 11}}, {a: {b: 22}}], 'a.b')
121 个模块,就是为了实现这些奇奇怪怪的需求而引入的。
考虑到大多数人在使用时,并不会用到这些特殊的用法,但是不得不为大量的冗余代码买单。lodash 就提供了 lodash-webpack-plugin 这个神奇的插件,在引入插件之后,打包的代码量从 32K 降低到了 1K,去除 Webpack 的运行时代码,只剩下不到 200 字节,减少了超过 99%。
而引入 lodash-webpack-plugin 后,map 方法的其他各种奇奇怪怪的用法就失效了,只剩下最基本的类似 Array map 的用法。
另一个例子,是 _.clamp 方法,它的功能是把某个数限制在某个区间。在正常情况下,这个方法能接收字符串并自动转化成数字,所以下面的代码会返回 20:
clamp('123', '1', '20') // 20
// 等价于 clamp(123, 1, 20) // 20
而一旦引入 lodash-webpack-plugin 后,它的返回值就变成了字符串 '123'
另一个例子是:
const sortBy = require('lodash/sortBy');
sortBy([{id: 3}, {id: 1}, {id: 2}], x => x.id);
类似的使用前后差异,还有很多。
整体来说这个 Plugin 的代码量并不多,稍微花一些时间多多少少就能知道它做了哪些事情。
首先,需要简单了解下 Webpack 的基本流程:
Webpack 在打包前,会从入口模块开始,针对每个模块使用 loader 处理得到标准 js 文件内容,再对这个文件进行语法树分析,拿到它的依赖,然后解析(resolve)出依赖的真实路径,然后对这个依赖进行一样的处理(广度优先遍历),最后得到一个依赖图(以及每个依赖压缩前的内容)。
lodash-webpack-plugin 插件做的事,就是在 webpack 的 afterResolve 钩子中,把某些 lodash 模块的资源路径替换掉,牺牲一些不常用的接口用法,达到见效打包体积的目的。
比如 map.js
会被替换成 _arrayMap
,顾名思义,替换之后它只能用来处理数组(或者类数组)。
更多的情况是,某模块 A
依赖的内部模块 _B
会被替换,这会导致 A
的核心逻辑可用,但是涉及到和 _B
相关的那一部分特性不被支持。
一个简单的例子,是 clamp
模块会依赖 toNumber
进行参数处理,也就是说它支持传入字符串参数,并在内部先处理成数字。但是使用 Plugin 后, Plugin 会把 toNumber
替换成 identity
(即a => a
),导致 clamp
不再支持字符串参数。如果传入的是字符串,返回的结果将发生变化。
Plugin 会默认移除(即替换成假模块)一大堆特性,同时提供了一个配置项让用户可以指定保留某些特性。相关的替换规则维护在 lodash/lodash-webpack-plugin/src/mapping.js 中。
也就是说,使用 lodash-webpack-plugin 之后,你的 lodash 就相当于变成了 严格模式 + 精简模式。和标准的 Webpack 并不完全一样。
你可能会问:「我只使用基础的 Webpack 功能,而且我使用之后肯定有测过,插件只是约束了我的用法而已,应该不会产生什么问题吧?」
大部分人可能都会这么认为(甚至我觉得插件作者也是这么想的),然而这种想法是错的。接下来你会知道,lodash-webpack-plugin 存在严重的隐患,不建议在任何项目中使用它。
如果第三方模块中也使用了 lodash 模块,而且用到了某些非常规用法,一旦使用了 Plugin 后,这个第三方模块使用的 lodash 的执行逻辑就可能发生变化。产生的后果可能是立即报错,也可能产生更严重的后果,即返回了和预期不一致的值,这个错误值在一系列流转之后,在另一个地方产生了 BUG。一旦出现了这种情况,因为这是一个第三方模块,问题的排查可能会非常困难。
仅凭这一点,就完全有充足的理由拒绝使用 lodash-webpack-plugin 了。毕竟为了区区几十 K 的代码大小,给自己的项目埋下一个雷,并不是一个明智的选择。
然而,lodash-webpack-plugin 的坑不只如此。
试想一下,如果你在 A 页面引入了一个 lodash 模块,甚至只是引入了一个第三方库,(或者是删除),导致功能逻辑完全不相干的 B 页面出现了 BUG,你会是怎样的心情?
对,如果你在使用 lodash-webpack-plugin,就是存在这样的可能。下面会分析,在文章结尾会给出一些示例代码。
Plugin 很「贴心」地为用户提供了「自动保留特性」的能力,拿 clamp
的例子来说,如果你的是引入 clamp
,那么它依赖的 toNumber
模块会被替换成 identity
。而如果你直接引入并使用 toNumber
,则 toNumber
不会被替换成 identity
。
那么,如果我们既引入了 clamp
,又引入了 toNumber
,结果会怎么样?
结论是:一般情况下,toNumber
会被替换成 identify
。
这样会导致一个结果:
原本你使用的 clamp
是不支持处理字符串的,但是在你引入 toNumber
后,它变得支持字符串了。
也许你会说,这看上去貌似不太会产生问题,因为 clamp
的功能是向后兼容的。但是万一是反过来的,你原本正常使用字符串参数,然后又删掉了 toNumber
呢?
一句话描述:就是在你引入/删除某个第三方模块(或者 lodash 模块),你的另一个不相干的代码逻辑(或者第三方模块)可能发生变化。
前面提到,Plugin 是在 Webpack 遍历解析模块的时候进行路径替换的,而模块的遍历是有先后顺序的。那么遍历顺序会影响到最终的替换结果吗?
经过简单的测试,发现还真会。
以在我们代码中同时引入 toNumber
和 clamp
为例,toNumber
会被 resolve 两次,一次是来自我们自己的代码,另一次是来自 clamp
. 我们需要构建两种代码结构,控制这两次 resolve 的先后顺序。
构造代码结构的逻辑是很简单的,因为 webpack 是广度优先遍历的,我们需要哪个模版被更靠后 resolve,把这个模块多套几层 import 就行。
下面的代码在不同的代码结构下会出现两种完全不同的结果:
// 下面两部分代码在不同文件中:
require('lodash/toNumber')('12')
require('lodash/clamp')('123', '1', '20')
可能会分别返回数字 12
和 20
, 或者分别返回字符串 '12'
和 '123'
.
后面这种结构的结果很显然是错误的。这里列一下文件结构:
// 文件 index.js
require('lodash/clamp')
require('./a')
// 文件 a.js
require('./b')
// 文件 b.js
const toNumber = require('lodash/toNumber')
console.log([
`toNumber('123')`,
toNumber('123'),
]) // 这个 toNumber 是错的,它返回了字符串 '123'
这个仓库中,我给出了三个会产生问题的例子:
例子 1:
你的代码本来是好的,当你引入了另一个 lodash 模块,你的代码挂了。
例子 2:
你的代码本来是好的,当你引入了某个第三方包后,你的代码挂了。
例子 3:
你使用了某个第三方包,这个包本来是好的,但是当你在你自己的业务代码中引入了某个 lodash 模块(也可能是引入了另一个三方包),导致前一个三方包挂了。
以上三个例子都能进一步衍生出:你只是重构了一下源码文件结构,然后某个功能/第三方模块挂掉了。
是否应该继续使用 lodash-webpack-plugin,结论已经很明显了。
我想说的是,就算现在没有发现上面这些问题,只看 lodash-webpack-plugin 那 100 多行的人肉维护的替换配置项,就足够你严肃考虑是否应该在生产环境使用它。
lodash/xx
甚至会导致负优化。