IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    你应该立即停止使用 lodash-webpack-plugin

    小鱼发表于 2023-08-30 14:17:23
    love 0

    最近某个页面在某次发布后,引用的一个第三方包发生了运行时报错。经过一番仔细的 DEBUG,发现在这次发布的构建中,lodash 的 _baseFlatten 模块被替换成了另一个一行代码的模块(后面确认是 lodash 的 head 模块)。

    同一个 git commit (而且有 lock file)二次打包就不复现了。(为什么两次打包结果,而且被替换的情况很难复现,仍然是一个谜)。

    后面经过其他有相关经验的同事的提醒,知道大概率和 lodash-webpack-plugin 这个插件有关系。以前只是粗浅地使用 lodash-webpack-plugin (以及 babel-plugin-lodash),对于它们具体做了什么不甚了解。

    于是花了一些时间研究了一下,发现这玩意(lodash-webpack-plugin)的坑挺大的。这篇文章将主要分以下部分:

    1. 背景:lodash-webpack-plugin 的产生背景(为什么需要这个插件)。如果你不知道 babel-plugin-lodash 和 lodash-webpack-plugin 有什么区别,那么你需要看完这一节。另外,个人建议深入了解下 tree-shacking 的相关原理、局限和使用姿势,这里不做过多讲解。
    2. lodash-webpack-plugin 存在哪些坑,为什么是坑。
    3. 一份 clone 下来就能运行的示例代码。
    4. 个人建议。

    太长不看版

    不要使用 lodash-webpack-plugin,坑太大了。

    如果没有耐心看完全文,可以直接看后文给的示例。

    背景

    目前的 Lodash 版本号为 4,官方提供了 lodash 和 lodash-es 两个 NPM 包。

    lodash 和 lodash-es

    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-plugin-lodash 做了什么

    这个 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-webpack-plugin

    考虑到大多数人在使用时,并不会用到这些特殊的用法,但是不得不为大量的冗余代码买单。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);

    类似的使用前后差异,还有很多。

    lodash-webpack-plugin 做了什么

    整体来说这个 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-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 模块),你的另一个不相干的代码逻辑(或者第三方模块)可能发生变化。

    坑三:插件内在缺陷,导致常规使用也会产生 BUG

    前面提到,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'
    

    相关示例

    https://github.com/meowtec/why-you-should-not-use-lodash-webpack-plugin​github.com/meowtec/why-you-should-not-use-lodash-webpack-plugin

    这个仓库中,我给出了三个会产生问题的例子:

    例子 1:

    你的代码本来是好的,当你引入了另一个 lodash 模块,你的代码挂了。

    例子 2:

    你的代码本来是好的,当你引入了某个第三方包后,你的代码挂了。

    例子 3:

    你使用了某个第三方包,这个包本来是好的,但是当你在你自己的业务代码中引入了某个 lodash 模块(也可能是引入了另一个三方包),导致前一个三方包挂了。

    以上三个例子都能进一步衍生出:你只是重构了一下源码文件结构,然后某个功能/第三方模块挂掉了。

    总结

    是否应该继续使用 lodash-webpack-plugin,结论已经很明显了。

    我想说的是,就算现在没有发现上面这些问题,只看 lodash-webpack-plugin 那 100 多行的人肉维护的替换配置项,就足够你严肃考虑是否应该在生产环境使用它。

    其他建议

    1. 如果你的项目代码量足够大,或者 lodash 使用得足够多,那么插件带来的优化可能已经不明显了。全量引入(打包到 vendor dll)可能是更优的选择,还能获得打包速度的提升(在使用 dll/external 的情况下继续使用 babel 插件或者手写 lodash/xx 甚至会导致负优化。
    2. You-Dont-Need-Lodash-Underscore 或者 ramda 等替代方案。


沪ICP备19023445号-2号
友情链接